spexcode 0.2.1 → 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 (45) hide show
  1. package/README.md +147 -103
  2. package/README.zh-CN.md +123 -88
  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 +214 -62
  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/guide.ts +63 -11
  10. package/spec-cli/src/harness.ts +48 -19
  11. package/spec-cli/src/help.ts +137 -49
  12. package/spec-cli/src/index.ts +31 -11
  13. package/spec-cli/src/issues.ts +48 -21
  14. package/spec-cli/src/layout.ts +4 -4
  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 +149 -62
  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-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
  30. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  31. package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
  32. package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
  33. package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
  34. package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
  35. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
  37. package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
  38. package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
  39. package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
  40. package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
  41. package/spec-dashboard/dist/index.html +2 -2
  42. package/spec-yatsu/src/cli.ts +89 -15
  43. package/spec-yatsu/src/scenariofresh.ts +100 -30
  44. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
  45. 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
 
@@ -122,13 +126,39 @@ pre-commit \`yatsu check-staged\` BLOCKS the commit.
122
126
  BODY (after the frontmatter): prose naming the measurement method — YATU ("You As The User"): the agent
123
127
  looks at / calls the real product surface, not an internal helper chosen to make the proof easy.
124
128
 
125
- 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
126
130
  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>|-]
131
+ spex yatsu eval <node> --scenario <name> (--pass | --fail) [--note <text>]
132
+ [--image <png> …repeatable] [--result <txt>|-] [--video <webm|mp4> [--timeline <json>]]
128
133
  The verdict is \`--pass\` or \`--fail\` (a measurement must commit to one — an unmeasured scenario is \`missing\`,
129
134
  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).
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.
132
162
 
133
163
  A botched filing (a junk e2e/smoke run, a wrong verdict) is undone through the SAME surface:
134
164
  spex yatsu retract <node> [--scenario <name>] [--last | --ts <iso>] [--note <why>]
@@ -162,7 +192,8 @@ PORTABILITY, and picking the right one is the whole discipline:
162
192
  Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
163
193
  icon, a lint budget, a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
164
194
  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.
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.
166
197
 
167
198
  MERGE: spexcode.local.json is layered over spexcode.json ONE LEVEL DEEP — per top-level section (dashboard,
168
199
  sessions, …), the two objects are shallow-merged with LOCAL WINNING per key; sections only one file names
@@ -191,19 +222,23 @@ Example:
191
222
  Counts compute slots, not total sessions: idle/asking/review/done do not
192
223
  occupy one. A policy number → committed spexcode.json; omit it to use the
193
224
  default, or tune higher/lower for the project's usual host.
194
- sessions.claudeCmd the UNNAMED default worker launcher for Claude (default
225
+ sessions.claudeCmd command used by the built-in "claude" launcher (default
195
226
  'claude --dangerously-skip-permissions'); env SPEXCODE_CLAUDE_CMD overrides.
196
- 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
197
228
  SPEXCODE_CODEX_CMD overrides.
198
- sessions.launchers NAMED launcher profiles (see LAUNCHERS).
229
+ sessions.launchers additional NAMED launcher profiles (see LAUNCHERS).
199
230
  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.
231
+ (required for no-choice creates). A portable NAME → committed.
201
232
  A claudeCmd/codexCmd or a launcher \`cmd\` that is a HOST-SPECIFIC ABSOLUTE PATH belongs in
202
233
  spexcode.local.json — the committed file must stay free of machine paths.
203
234
 
204
235
  ── LAUNCHERS (the profile block, split across the two files) ──
205
236
  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:
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:
207
242
  "launchers": { "<name>": { "harness": "claude" | "codex", "cmd": "<launch command>" } }
208
243
  \`harness\` defaults to "claude"; \`cmd\` is required. Because \`cmd\` is a machine fact (an abs wrapper path),
209
244
  the DEFINITION lives in the gitignored spexcode.local.json, while the portable defaultLauncher NAME sits
@@ -230,6 +265,23 @@ in the committed spexcode.json — the merge keeps both:
230
265
  spexcode.local.json.
231
266
  The gateway password is NEVER read from these files (flag/env only), so serve.public stays committable.
232
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
+
233
285
  ── ISSUES (spexcode.json — portable policy) ──
234
286
  issues.enabled the issues-workflow on/off switch (default ON). OFF silences the post-merge nudge and
235
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
  }