spexcode 0.2.1 → 0.2.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.
Files changed (52) hide show
  1. package/README.md +158 -103
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +24 -1
  4. package/spec-cli/src/attach.ts +50 -0
  5. package/spec-cli/src/cli.ts +217 -64
  6. package/spec-cli/src/client.ts +47 -9
  7. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  8. package/spec-cli/src/guide.ts +79 -21
  9. package/spec-cli/src/harness.ts +53 -29
  10. package/spec-cli/src/help.ts +137 -49
  11. package/spec-cli/src/index.ts +31 -11
  12. package/spec-cli/src/issues.ts +48 -21
  13. package/spec-cli/src/layout.ts +3 -5
  14. package/spec-cli/src/lint.ts +34 -5
  15. package/spec-cli/src/localIssues.ts +44 -60
  16. package/spec-cli/src/materialize.ts +4 -2
  17. package/spec-cli/src/mentions.ts +22 -1
  18. package/spec-cli/src/pty-bridge.ts +39 -4
  19. package/spec-cli/src/ranker.ts +31 -12
  20. package/spec-cli/src/search.bench.mjs +30 -7
  21. package/spec-cli/src/search.ts +39 -0
  22. package/spec-cli/src/sessions.ts +160 -69
  23. package/spec-cli/src/specs.ts +16 -4
  24. package/spec-cli/src/supervise.ts +30 -6
  25. package/spec-cli/src/tree.ts +118 -0
  26. package/spec-cli/templates/hooks/post-merge +2 -2
  27. package/spec-cli/templates/hooks/pre-commit +34 -15
  28. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  29. package/spec-cli/templates/spexcode.json +7 -0
  30. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  31. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +27 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-B5leylLf.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-RHNECU6x.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-YLD6IOmC.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-ZnOwskMZ.js +1 -0
  40. package/spec-dashboard/dist/assets/index-BdRQfrkR.js +41 -0
  41. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +1 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-yatsu/src/cli.ts +128 -26
  44. package/spec-yatsu/src/evaltab.ts +7 -6
  45. package/spec-yatsu/src/filing.ts +6 -3
  46. package/spec-yatsu/src/proof.ts +10 -0
  47. package/spec-yatsu/src/scenariofresh.ts +100 -30
  48. package/spec-yatsu/src/sidecar.ts +25 -3
  49. package/spec-yatsu/src/timeline.ts +53 -23
  50. package/README.zh-CN.md +0 -135
  51. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
  52. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
@@ -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
  }
@@ -8,7 +8,8 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
8
8
  It always operates on the repo of your current directory — that cwd is the only "which repo" knob.
9
9
  (Dogfooding an unpublished HEAD from a source checkout? \`npm link\` at the repo ROOT — that links
10
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 source link ships no
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
12
13
  prebuilt dashboard dist — \`spex dashboard\` needs a manual dashboard build, or use the dev server.)
13
14
 
14
15
  2. Adopt a repo
@@ -24,6 +25,9 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
24
25
  spex dashboard # serves the bundled board on :5173, proxying /api
25
26
  Point it at another backend with --api-port (pairs with \`spex serve --port\`); one dashboard per
26
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).
27
31
  (Dogfood/source alternative: API_URL=http://localhost:<port> npm run dev in spec-dashboard —
28
32
  the dev server; "dashboard": { "apiUrl": "..." } in spexcode.json applies only to that layout.)
29
33
 
@@ -90,9 +94,10 @@ before merge. \`spex init\` seeds the first tree; \`spex guide yatsu\` covers th
90
94
  const YATSU = `spex guide yatsu — the yatsu.md file format
91
95
 
92
96
  A yatsu.md sits BESIDE a node's spec.md and says how to MEASURE the node's loss — the gap between live
93
- behaviour and the spec. It is optional, but a FRONTEND node (its code: includes a UI file
94
- .jsx/.tsx/.vue/.svelte/.css, or the dashboard) with no yatsu.md is a blind spot: \`spex yatsu scan\` flags
95
- it \`yatsu-uncovered\`. yatsu defines no DSL and RUNS NOTHING — the agent measures; yatsu keeps score.
97
+ behaviour and the spec. It is optional, but a node that governs SOURCE code (its code: includes a file whose extension is in
98
+ \`lint.sourceExtensions\` — default .ts/.tsx/.js/.jsx, set it for a Rust/Go/Python tree) with no yatsu.md is
99
+ a blind spot: \`spex yatsu scan\` flags it \`yatsu-uncovered\`. yatsu defines no DSL and RUNS NOTHING — the
100
+ agent measures; yatsu keeps score.
96
101
 
97
102
  FRONTMATTER: a \`scenarios:\` list (a YAML block sequence of mappings). Each scenario:
98
103
  name REQUIRED. Unique within the file — it keys the sidecar and \`--scenario <name>\`.
@@ -122,13 +127,43 @@ pre-commit \`yatsu check-staged\` BLOCKS the commit.
122
127
  BODY (after the frontmatter): prose naming the measurement method — YATU ("You As The User"): the agent
123
128
  looks at / calls the real product surface, not an internal helper chosen to make the proof easy.
124
129
 
125
- MEASURING AND FILING: the agent runs the scenario however it likes (a browser screenshot, an API
130
+ MEASURING AND FILING: the agent runs the scenario however it likes (a browser run, an API
126
131
  transcript, a by-hand pass), compares the result to \`expected\`, and files it:
127
- spex yatsu eval <node> --scenario <name> (--pass | --fail) [--note <text>] [--image <png> | --result <txt>|-]
132
+ spex yatsu eval <node> --scenario <name> (--pass | --fail) [--note <text>]
133
+ [--image <png> …repeatable] [--result <txt>|-] [--video <webm|mp4> [--timeline <json>]]
128
134
  The verdict is \`--pass\` or \`--fail\` (a measurement must commit to one — an unmeasured scenario is \`missing\`,
129
135
  not a hedged fail). \`--note <text>\` is an OPTIONAL one-line annotation on either (why it failed, how far a
130
- pass sits from ideal); it does NOT replace evidence — the image/transcript is the captured actual behaviour.
131
- Frontend \`--image <png>\` (visual evidence); backend \`--result <txt>\` (a transcript; \`-\` reads stdin).
136
+ pass sits from ideal); it does NOT replace evidence — the image/video/transcript is the captured actual behaviour.
137
+ PICK THE EVIDENCE KIND BY WHAT THE BEHAVIOUR DOES OVER TIME:
138
+ MOVES / is timed → \`--video <webm|mp4>\`. Terminal scroll or redraw, an animation or transition, media
139
+ playback, a multi-step interaction flow, keyboard timing — a still of a moving thing
140
+ proves the wrong thing; RECORD the run (e.g. playwright \`recordVideo\` on the context).
141
+ STEP EVIDENCE gets a STEP-MAP: when the evidence unfolds in steps, carry named steps
142
+ anchored to a POSITION on the evidence's OWN axis, EXPORTED BY THE RUN that produced it
143
+ — never a value the agent eyeballs off the finished artefact afterwards (that's
144
+ misaligned and dishonest, worse than none). \`--timeline <json>\` carries one; its \`axis\`
145
+ is the evidence's: a video is \`time\` (ms), a transcript \`line\`, a still SEQUENCE \`frame\`,
146
+ an action trace \`index\` (the set is OPEN — an unknown axis just renders as a bare number).
147
+ \`at\` = the position on that axis, \`step\` = a short name for that moment; copy this shape:
148
+ { "v": 2, "axis": "time",
149
+ "events": [ { "at": 0, "step": "open board" },
150
+ { "at": 1200, "step": "type query" } ] }
151
+ The run exports it: in whatever drives the evidence — Playwright, a computer-use hand, a
152
+ CLI harness stamping line numbers — take a baseline and at EACH real step push
153
+ \`{ at: <position>, step: "…" }\`; dump that array as \`--timeline\`. Its \`axis\` MUST match the
154
+ evidence it rides (a \`line\` map needs a \`--result\` transcript, a \`time\` map a \`--video\`);
155
+ skip it for a short single-step artefact. (Legacy \`{ "v": 1, "events": [{ "tMs" }] }\` — the
156
+ time axis with \`tMs\` — is still accepted, read as \`axis: "time"\`.)
157
+ STATIC end state → \`--image <png>\` (repeatable — N stills). Layout, an icon, copy, one rendered frame.
158
+ backend / CLI → \`--result <txt>\` (a transcript; \`-\` reads stdin). A STRUCTURED export (a JSON
159
+ \`--export-json\`, an API payload, a metrics dump) is recognized BY CONTENT and kept as
160
+ \`data\` — rendered as a validatable data block, not flattened into scrolling transcript
161
+ text; free-form output stays a transcript. You pick the flag; the KIND follows the bytes.
162
+ The flags combine in ONE filing — several stills can ride beside the clip of the same run.
163
+ ANCHOR DISCIPLINE: a reading's \`codeSha\` is HEAD at filing time, and a git sha names only a COMMIT — an
164
+ uncommitted change has none. So measure the tree you are about to commit, COMMIT it, then file; confidence
165
+ is earned on the working tree, but the anchor can only land after the commit. Filing from a dirty tree
166
+ mis-anchors the reading (its sha lacks the change it measured) and it goes stale the moment you commit.
132
167
 
133
168
  A botched filing (a junk e2e/smoke run, a wrong verdict) is undone through the SAME surface:
134
169
  spex yatsu retract <node> [--scenario <name>] [--last | --ts <iso>] [--note <why>]
@@ -141,7 +176,7 @@ THE SCOREBOARD: readings live in yatsu.evals.ndjson beside the yatsu.md — one
141
176
  (a second git-as-database axis). Freshness is derived live from git: a reading goes STALE when a governed
142
177
  code file, the scenario (the yatsu.md), or the evaluator moves since it was filed.
143
178
  spex yatsu scan [--changed] blind spots: yatsu-schema (malformed) · yatsu-drift (stale) ·
144
- yatsu-missing (never measured) · yatsu-uncovered (frontend, no yatsu.md) ·
179
+ yatsu-missing (never measured) · yatsu-uncovered (governed source, no yatsu.md) ·
145
180
  yatsu-owners (a file governed by > maxOwners scenarios — split it)
146
181
  spex yatsu show <node> the reading timeline (verdict · freshness · evidence), newest first
147
182
  spex yatsu clean GC the content-addressed evidence cache`
@@ -156,13 +191,14 @@ PORTABILITY, and picking the right one is the whole discipline:
156
191
  identity, lint budgets, launcher NAMES. "Git is the database": tracked so the
157
192
  team shares ONE configuration.
158
193
  spexcode.local.json GITIGNORED — host-specific, never committed. Absolute launcher paths, cert/secret
159
- paths, private-overlay mode. Layered OVER spexcode.json (see MERGE below); an env
160
- var (SPEXCODE_CLAUDE_CMD, …) still overrides both at its read site.
194
+ paths, private-overlay mode. Layered OVER spexcode.json (see MERGE below); a
195
+ targeted env override (SPEXCODE_CODEX_SERVER_CMD, …) still wins at its read site.
161
196
 
162
197
  Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
163
198
  icon, a lint budget, a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
164
199
  PATH of a launcher wrapper, a TLS cert path, private mode are machine facts → gitignored spexcode.local.json.
165
- Both files are optional; omit any field to take its default.
200
+ Both files are optional; omit any field to take its default, except \`sessions.defaultLauncher\` when using
201
+ \`spex new\` or the dashboard without an explicit launcher choice.
166
202
 
167
203
  MERGE: spexcode.local.json is layered over spexcode.json ONE LEVEL DEEP — per top-level section (dashboard,
168
204
  sessions, …), the two objects are shallow-merged with LOCAL WINNING per key; sections only one file names
@@ -191,19 +227,24 @@ Example:
191
227
  Counts compute slots, not total sessions: idle/asking/review/done do not
192
228
  occupy one. A policy number → committed spexcode.json; omit it to use the
193
229
  default, or tune higher/lower for the project's usual host.
194
- sessions.claudeCmd the UNNAMED default worker launcher for Claude (default
195
- 'claude --dangerously-skip-permissions'); env SPEXCODE_CLAUDE_CMD overrides.
196
- sessions.codexCmd the UNNAMED default worker launcher for Codex (default 'codex --yolo'); env
197
- SPEXCODE_CODEX_CMD overrides.
198
- sessions.launchers NAMED launcher profiles (see LAUNCHERS).
230
+ sessions.launchers the NAMED launcher profiles (see LAUNCHERS). \`spex init\` seeds "claude" and
231
+ "codex" here as ordinary entries; edit/add more like any other.
199
232
  sessions.defaultLauncher the launcher name a create with no explicit --launcher/dropdown pick uses
200
- (else the unnamed claudeCmd/codexCmd path). A portable NAME → committed.
201
- A claudeCmd/codexCmd or a launcher \`cmd\` that is a HOST-SPECIFIC ABSOLUTE PATH belongs in
202
- spexcode.local.json — the committed file must stay free of machine paths.
233
+ (required for no-choice creates). A portable NAME → committed.
234
+ A launcher \`cmd\` that is a HOST-SPECIFIC ABSOLUTE PATH belongs in spexcode.local.json — the committed file
235
+ must stay free of machine paths.
203
236
 
204
237
  ── LAUNCHERS (the profile block, split across the two files) ──
205
238
  A named launcher profile fixes BOTH a session's harness AND its exact launch command; a create picks one
206
- by name, and the chosen name is persisted on the record so a resume reuses the same auth. Shape:
239
+ by name with --launcher/the dashboard dropdown, and the chosen name is persisted on the record so a resume
240
+ reuses the same auth. There are NO magic built-ins: \`spex init\` SEEDS "claude" and "codex" as ordinary
241
+ named launchers,
242
+ "claude" → { "harness": "claude", "cmd": "claude --dangerously-skip-permissions" }
243
+ "codex" → { "harness": "codex", "cmd": "codex --yolo" }
244
+ after which they are edited (or removed) exactly like any launcher you add. To run workers under an auth
245
+ wrapper (e.g. reclaude), point a launcher's \`cmd\` at it in spexcode.local.json — there is no environment
246
+ override that rewrites a launcher's command. Add more profiles when a project needs named auth/config-dir
247
+ variants. Shape:
207
248
  "launchers": { "<name>": { "harness": "claude" | "codex", "cmd": "<launch command>" } }
208
249
  \`harness\` defaults to "claude"; \`cmd\` is required. Because \`cmd\` is a machine fact (an abs wrapper path),
209
250
  the DEFINITION lives in the gitignored spexcode.local.json, while the portable defaultLauncher NAME sits
@@ -230,6 +271,23 @@ in the committed spexcode.json — the merge keeps both:
230
271
  spexcode.local.json.
231
272
  The gateway password is NEVER read from these files (flag/env only), so serve.public stays committable.
232
273
 
274
+ ── BACKEND ROUTING (not a config field — how a \`spex\` command picks its backend) ──
275
+ One host runs many projects' backends, and a shell inherits the launching backend's SPEXCODE_API_URL —
276
+ an env var cannot prove intent (exported-on-this-command vs inherited look identical), so the client
277
+ resolves its backend per this ladder, flag first:
278
+ 1. --api <url> explicit flag on any session verb — ALWAYS wins (--port <n> is localhost
279
+ sugar for --api http://127.0.0.1:<n>).
280
+ 2a. worker (SPEXCODE_SESSION_ID set): env SPEXCODE_API_URL — the backend-injected lifeline; cwd
281
+ discovery never steals it.
282
+ 2b. human (no session id): the cwd project's RECORDED live backend — \`spex serve\` records {url,pid}
283
+ in ~/.spexcode/projects/<enc>/backend.json at bind time; the reader
284
+ health-probes before trusting (a dead record is ignored).
285
+ 3. the other side as fallback (human with no live record → env; worker with no env → record).
286
+ 4. default http://127.0.0.1:$PORT||8787.
287
+ WRITES are project-bound: every mutating verb (new/merge/send/close/rename/rawkey/reopen/exit) refuses
288
+ loudly when the resolved backend serves a DIFFERENT same-host project — an explicit --api/--port skips
289
+ the guard (the flag is the proof of intent). Reads point anywhere.
290
+
233
291
  ── ISSUES (spexcode.json — portable policy) ──
234
292
  issues.enabled the issues-workflow on/off switch (default ON). OFF silences the post-merge nudge and
235
293
  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 }
@@ -50,14 +49,15 @@ export interface Harness {
50
49
 
51
50
  // --- launch / sessionId ---
52
51
  // the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
53
- // visible TUI with `--remote` pointed at it. `cmd`, when given, is the SESSION's persisted launcher command
54
- // ([[launcher-select]]) and OVERRIDES the ambient env→config→default resolution so a session created under a
55
- // 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).
52
+ // visible TUI with `--remote` pointed at it. `cmd` is the SESSION's persisted launcher command
53
+ // ([[launcher-select]]) the resolved `cmd` of the named launcher it was created under. A session always
54
+ // carries one (pinned at creation), so resume keeps that exact command (and auth), never reverting to a
55
+ // global default. Omitted is only for tests and old records before launch_cmd was pinned (→ the bare default).
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`,
60
- // when given, wins; else the ambient env→config→default resolution. The launch owner PINS this on the record
59
+ // when given (the named launcher's `cmd`), IS the answer; else the harness's bare built-in default there is
60
+ // no env/config-field resolution (claude/codex are ordinary named launchers). The launch owner PINS this on the record
61
61
  // at creation so a resume replays the EXACT launcher that created the conversation — never re-resolving
62
62
  // against a since-changed default, which would point `--resume` at the wrong config dir and lose the
63
63
  // transcript ([[launcher-select]], the resume-launcher-pin). launchCmd builds its invocation ON TOP of this.
@@ -226,11 +226,12 @@ function shQuote(s: string): string {
226
226
  return `'${s.replace(/'/g, `'\\''`)}'`
227
227
  }
228
228
 
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.
229
+ // the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
230
+ // the 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. The launcher, never a raw `tsx cli.ts` pair: it owns tsx resolution and the
232
+ // mid-merge guard (conflicted source → one line + exit 75, not a stacktrace).
232
233
  const PKG = fileURLToPath(new URL('..', import.meta.url))
233
- const SPEX = `${tsxBin(PKG)} ${join(PKG, 'src', 'cli.ts')}`
234
+ const SPEX = join(PKG, 'bin', 'spex.mjs')
234
235
 
235
236
  // @@@ replyViaSocket - OPTIMISTIC-after-liveness delivery: connect to the LIVE rendezvous socket and WRITE the
236
237
  // `{type:reply}\n` line; once that line FLUSHES to the socket with no immediate transport error, the reply is on
@@ -359,7 +360,7 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
359
360
  bypassProbe.set(binary, ok)
360
361
  return ok
361
362
  }
362
- export function codexLaunchCommand(_id: string, codexCmd = process.env.SPEXCODE_CODEX_CMD || 'codex --yolo', serverCmd?: string, dir = runtimeRoot()): string {
363
+ export function codexLaunchCommand(_id: string, codexCmd = 'codex --yolo', serverCmd?: string, dir = runtimeRoot()): string {
363
364
  const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
364
365
  // The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
365
366
  // the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
@@ -934,9 +935,11 @@ const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostTo
934
935
  const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
935
936
 
936
937
  // the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
937
- // launchCmd and baseCmd so the two never diverge: `cmd` override wins, else ambient env→config→default.
938
- const claudeBaseCmd = (cmd?: string) => cmd || process.env.SPEXCODE_CLAUDE_CMD || readConfig(mainCheckout()).sessions?.claudeCmd || 'claude --dangerously-skip-permissions'
939
- const codexBaseCmd = (cmd?: string) => cmd || process.env.SPEXCODE_CODEX_CMD || readConfig(mainCheckout()).sessions?.codexCmd || 'codex --yolo'
938
+ // launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The bare default is only
939
+ // the fallback for a truly-old record with NO pinned cmd and NO launcher name there is no env/config-field
940
+ // resolution, because claude/codex are ordinary named launchers now ([[launcher-select]]), resolved by name.
941
+ const claudeBaseCmd = (cmd?: string) => cmd || 'claude --dangerously-skip-permissions'
942
+ const codexBaseCmd = (cmd?: string) => cmd || 'codex --yolo'
940
943
 
941
944
  export const claudeHarness: Harness = {
942
945
  id: 'claude',
@@ -1031,8 +1034,7 @@ export const codexHarness: Harness = {
1031
1034
  // every adapter — materialize iterates this to render each harness's artifacts in one pass.
1032
1035
  export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness]
1033
1036
 
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.
1037
+ // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
1036
1038
  export const defaultHarness: Harness = claudeHarness
1037
1039
 
1038
1040
  // resolve an adapter by id (the detector). Throws on an unknown id — fail loud, never silently default.
@@ -1044,27 +1046,49 @@ export function harnessById(id: string): Harness {
1044
1046
 
1045
1047
  // --- named launcher profiles ([[launcher-select]]) ----------------------------------------------------------
1046
1048
  // a launcher = a `{ harness, cmd }` pair in spexcode.json's `sessions.launchers`, keyed by a human-chosen name.
1047
- // harness defaults to claude. resolveLauncher throws fail-loud on an unknown name (a session must never
1048
- // silently launch under the wrong auth) and validates the harness id.
1049
+ // `claude` and `codex` are NOT special built-ins `spex init` SEEDS them as ordinary named launchers (with the
1050
+ // regular command path), so they are edited like any other. harness defaults to claude. resolveLauncher throws
1051
+ // fail-loud on an unknown name (a session must never silently launch under the wrong auth) and validates the
1052
+ // harness id. There is NO env-derived built-in fallback: the dropdown lists exactly the config's real launchers.
1049
1053
  export type Launcher = { name: string; harness: string; cmd: string }
1054
+ export type LauncherDefault = { default: string | null; error: string | null }
1050
1055
 
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.
1056
+ // the configured named launchers from spexcode.json, as a stable name-sorted list (for the dashboard dropdown +
1057
+ // the CLI). Picking a launcher is the ONLY launch choice; the old separate harness pick is gone.
1053
1058
  export function launcherList(root = mainCheckout()): Launcher[] {
1054
1059
  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 }))
1060
+ return Object.keys(m)
1061
+ .map((name) => ({ name, harness: m[name].harness || defaultHarness.id, cmd: m[name].cmd }))
1062
+ .sort((a, b) => a.name.localeCompare(b.name))
1056
1063
  }
1057
1064
 
1065
+ export const MISSING_DEFAULT_LAUNCHER_ERROR =
1066
+ 'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})'
1067
+
1058
1068
  // 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.
1069
+ // explicit choice resolves. Missing config is a fail-loud setup error, never an implicit fallthrough to a
1070
+ // `claude` launcher (which `spex init` seeds by name, so a default can point at it explicitly).
1061
1071
  export function defaultLauncher(root = mainCheckout()): string {
1062
- return readConfig(root).sessions?.defaultLauncher || ''
1072
+ const name = readConfig(root).sessions?.defaultLauncher?.trim()
1073
+ if (!name) throw new Error(MISSING_DEFAULT_LAUNCHER_ERROR)
1074
+ return name
1075
+ }
1076
+
1077
+ export function launcherDefault(root = mainCheckout()): LauncherDefault {
1078
+ try {
1079
+ const name = defaultLauncher(root)
1080
+ resolveLauncher(name, root)
1081
+ return { default: name, error: null }
1082
+ } catch (e) {
1083
+ return { default: null, error: String((e as Error).message || e) }
1084
+ }
1063
1085
  }
1064
1086
 
1065
1087
  export function resolveLauncher(name: string, root = mainCheckout()): Launcher {
1066
1088
  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 }
1089
+ if (!l) throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`)
1090
+ if (!l.cmd) throw new Error(`launcher '${name}' is missing cmd`)
1091
+ const resolved = { name, harness: l.harness || defaultHarness.id, cmd: l.cmd }
1092
+ harnessById(resolved.harness) // validate the harness id fail-loud
1093
+ return resolved
1070
1094
  }