spexcode 0.2.2 → 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 (30) hide show
  1. package/README.md +26 -15
  2. package/package.json +1 -1
  3. package/spec-cli/src/cli.ts +3 -2
  4. package/spec-cli/src/guide.ts +38 -32
  5. package/spec-cli/src/harness.ts +26 -31
  6. package/spec-cli/src/help.ts +1 -1
  7. package/spec-cli/src/layout.ts +1 -3
  8. package/spec-cli/src/lint.ts +34 -5
  9. package/spec-cli/src/sessions.ts +11 -7
  10. package/spec-cli/templates/spexcode.json +7 -0
  11. package/spec-dashboard/dist/assets/{Dashboard-BwZ2KzxB.js → Dashboard-Dlg78cbC.js} +3 -3
  12. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
  13. package/spec-dashboard/dist/assets/{FoldToggle-GwE0-k1d.js → FoldToggle-B5leylLf.js} +1 -1
  14. package/spec-dashboard/dist/assets/{IssuesPage-B17pnl9I.js → IssuesPage-C2yFXiO-.js} +1 -1
  15. package/spec-dashboard/dist/assets/{MobileApp-WEZbR8M1.js → MobileApp-RHNECU6x.js} +1 -1
  16. package/spec-dashboard/dist/assets/{SessionInterface-Sh8kHpnj.js → SessionInterface-YLD6IOmC.js} +1 -1
  17. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
  18. package/spec-dashboard/dist/assets/{Settings-Dgtg-Xb9.js → Settings-ZnOwskMZ.js} +1 -1
  19. package/spec-dashboard/dist/assets/{index-Dd0_U5rk.js → index-BdRQfrkR.js} +2 -2
  20. package/spec-dashboard/dist/assets/{index-CCmnCbKS.css → index-DEc5Ru3l.css} +1 -1
  21. package/spec-dashboard/dist/index.html +2 -2
  22. package/spec-yatsu/src/cli.ts +39 -11
  23. package/spec-yatsu/src/evaltab.ts +7 -6
  24. package/spec-yatsu/src/filing.ts +6 -3
  25. package/spec-yatsu/src/proof.ts +10 -0
  26. package/spec-yatsu/src/sidecar.ts +25 -3
  27. package/spec-yatsu/src/timeline.ts +53 -23
  28. package/README.zh-CN.md +0 -170
  29. package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +0 -3
  30. package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +0 -9
package/README.md CHANGED
@@ -1,19 +1,34 @@
1
- <img src="docs/sdd-tuxedo-pooh.png" alt="tuxedo pooh meme" width="420">
1
+ <div align="center">
2
2
 
3
- # SpexCode
3
+ <img src="docs/banner.png" alt="SpexCode" width="720">
4
+
5
+ <p>
6
+ <a href="https://www.npmjs.com/package/spexcode"><img alt="npm" src="https://img.shields.io/npm/v/spexcode?logo=npm&logoColor=white&color=cb3837"></a>
7
+ <img alt="license: MIT" src="https://img.shields.io/badge/license-MIT-2f81f7">
8
+ <img alt="node &ge; 22" src="https://img.shields.io/badge/node-%E2%89%A5%2022-3fb950?logo=nodedotjs&logoColor=white">
9
+ <a href="https://spexcode.net"><img alt="docs" src="https://img.shields.io/badge/docs-spexcode.net-8957e5"></a>
10
+ </p>
11
+
12
+ </div>
4
13
 
5
14
  Spec-driven development with AI agents in the loop. SpexCode keeps a versioned tree of specs inside
6
15
  your git repo, links every spec to the code it governs, and runs a session manager that dispatches
7
16
  coding agents into isolated worktrees. You review and merge; the tool keeps intent and
8
17
  implementation from drifting apart. (All screenshots below are this very repo on its own board.)
9
18
 
10
- English | [中文](./README.zh-CN.md) · Docs: [spexcode.net](https://spexcode.net) · License: MIT
19
+ [![This repo's spec tree, grown from its own git history](docs/spec-tree-hero.gif)](https://spexcode.net/assets/spec-tree-growth.mp4)
20
+
21
+ <sub>▶ This repo's own spec tree, replayed from its git history — 160 spec nodes growing over three weeks. Click for the [full video](https://spexcode.net/assets/spec-tree-growth.mp4).</sub>
22
+
23
+ English | [中文](./docs/README.zh-CN.md) · Docs: [spexcode.net](https://spexcode.net) · License: MIT
11
24
 
12
25
  Quick links: [the model](#the-model) · [quick start](#quick-start) ·
13
26
  [agents](#working-with-agents) · [yatsu](#measuring-behavior-yatsu) · [config](#configuration)
14
27
 
15
28
  ## The model
16
29
 
30
+ <div align="center"><img src="docs/sdd-tuxedo-pooh.png" alt="spec-driven development meme" width="260"></div>
31
+
17
32
  A spec node is a directory under `.spec/` containing a `spec.md`: frontmatter (title, status, a
18
33
  `code:` list of the files it governs) plus a prose body stating what that part of the system is
19
34
  supposed to do, right now. Nodes nest, so the tree mirrors how you think about the project rather
@@ -123,7 +138,10 @@ prompt stays task-only. More on this mode of working:
123
138
 
124
139
  ## Measuring behavior: yatsu
125
140
 
126
- yatsu is the measuring half of [the loop](#the-optimization-loop). A spec says what a part should do; a
141
+ yatsu short for **You As The Stupid User** is the measuring half of
142
+ [the loop](#the-optimization-loop): you measure behavior from the product's real surface, the way a
143
+ clueless real end user would touch it, not through an internal helper or shortcut that makes the
144
+ proof easy. A spec says what a part should do; a
127
145
  `yatsu.md` beside it says how to check. Each scenario is a plain description plus an expected
128
146
  result. yatsu itself runs nothing (no DSL, no runner). An agent runs the scenario however it can:
129
147
  a test file, a real browser, or just clicking through by hand and screenshotting. It compares
@@ -172,23 +190,16 @@ to), and `spex guide config` documents every field. The other
172
190
  manuals are `spex guide` (the workflow), `spex guide spec`, and `spex guide yatsu`; `spex help`
173
191
  maps the commands.
174
192
 
175
- ## Status
176
-
177
- SpexCode develops itself with itself: the `.spec/` tree in this repo is the tool's own spec, and
178
- every change to the tool lands through the same worker/manager loop it implements. The dashboard
179
- you install is the one it was built on. Known warts: `spex session new --help`
180
- doesn't print help, it creates a session named `--help` (dispatch with `spex new`). And the
181
- altitude lint currently reports forty-odd warnings against this repo's own specs; I haven't had
182
- time to pay that down.
183
- The first public write-up was posted on the [LINUX DO](https://linux.do) community — thanks for
184
- the first round of discussion there.
185
-
186
193
  ## Contributing
187
194
 
188
195
  [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) gets you from a clone to a first merged change.
189
196
  [`docs/AGENT_GUIDE.md`](docs/AGENT_GUIDE.md) has the full mechanics of the node model and the
190
197
  reflexive config system.
191
198
 
199
+ ## Credit
200
+
201
+ First introduced on the [LINUX DO](https://linux.do) community — thanks to everyone there for the first round of discussion.
202
+
192
203
  ## License
193
204
 
194
205
  [MIT](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -122,11 +122,12 @@ async function withWatchEdge<T>(selectors: string[], intervalMs: number, body: (
122
122
  async function resolveSelectorOrExit(selector: string): Promise<string> {
123
123
  if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch)'); process.exit(2) }
124
124
  const { resolveClientSession } = await import('./client.js')
125
+ const { sessionLabel } = await import('./sessions.js')
125
126
  const r = await resolveClientSession(selector)
126
127
  if ('ok' in r) return r.ok.id
127
128
  if ('none' in r) { console.error(`spex: no such session: ${selector}`); process.exit(2) }
128
129
  console.error(`spex: ambiguous selector "${selector}" matches ${r.ambiguous.length} sessions — be more specific:`)
129
- for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${s.node || s.branch || s.id}`)
130
+ for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${sessionLabel(s)}`)
130
131
  process.exit(2)
131
132
  }
132
133
 
@@ -342,7 +343,7 @@ if (cmd === 'serve') {
342
343
  if (has('json')) { console.log(JSON.stringify(r, null, 2)) }
343
344
  else {
344
345
  const g = r.gates
345
- console.log(`review ${r.node || r.branch || r.id} [${r.id}]`)
346
+ console.log(`review ${r.label} [${r.id}]`)
346
347
  console.log(` ahead of main : ${r.ahead} commit(s)`)
347
348
  console.log(` uncommitted : ${r.dirtyNonRuntime} non-runtime file(s)`)
348
349
  console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
@@ -94,9 +94,10 @@ before merge. \`spex init\` seeds the first tree; \`spex guide yatsu\` covers th
94
94
  const YATSU = `spex guide yatsu — the yatsu.md file format
95
95
 
96
96
  A yatsu.md sits BESIDE a node's spec.md and says how to MEASURE the node's loss — the gap between live
97
- behaviour and the spec. It is optional, but a FRONTEND node (its code: includes a UI file
98
- .jsx/.tsx/.vue/.svelte/.css, or the dashboard) with no yatsu.md is a blind spot: \`spex yatsu scan\` flags
99
- 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.
100
101
 
101
102
  FRONTMATTER: a \`scenarios:\` list (a YAML block sequence of mappings). Each scenario:
102
103
  name REQUIRED. Unique within the file — it keys the sidecar and \`--scenario <name>\`.
@@ -138,22 +139,26 @@ PICK THE EVIDENCE KIND BY WHAT THE BEHAVIOUR DOES OVER TIME:
138
139
  playback, a multi-step interaction flow, keyboard timing — a still of a moving thing
139
140
  proves the wrong thing; RECORD the run (e.g. playwright \`recordVideo\` on the context).
140
141
  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\`.)
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"\`.)
155
157
  STATIC end state → \`--image <png>\` (repeatable — N stills). Layout, an icon, copy, one rendered frame.
156
- backend / CLI → \`--result <txt>\` (a transcript; \`-\` reads stdin).
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.
157
162
  The flags combine in ONE filing — several stills can ride beside the clip of the same run.
158
163
  ANCHOR DISCIPLINE: a reading's \`codeSha\` is HEAD at filing time, and a git sha names only a COMMIT — an
159
164
  uncommitted change has none. So measure the tree you are about to commit, COMMIT it, then file; confidence
@@ -171,7 +176,7 @@ THE SCOREBOARD: readings live in yatsu.evals.ndjson beside the yatsu.md — one
171
176
  (a second git-as-database axis). Freshness is derived live from git: a reading goes STALE when a governed
172
177
  code file, the scenario (the yatsu.md), or the evaluator moves since it was filed.
173
178
  spex yatsu scan [--changed] blind spots: yatsu-schema (malformed) · yatsu-drift (stale) ·
174
- yatsu-missing (never measured) · yatsu-uncovered (frontend, no yatsu.md) ·
179
+ yatsu-missing (never measured) · yatsu-uncovered (governed source, no yatsu.md) ·
175
180
  yatsu-owners (a file governed by > maxOwners scenarios — split it)
176
181
  spex yatsu show <node> the reading timeline (verdict · freshness · evidence), newest first
177
182
  spex yatsu clean GC the content-addressed evidence cache`
@@ -186,8 +191,8 @@ PORTABILITY, and picking the right one is the whole discipline:
186
191
  identity, lint budgets, launcher NAMES. "Git is the database": tracked so the
187
192
  team shares ONE configuration.
188
193
  spexcode.local.json GITIGNORED — host-specific, never committed. Absolute launcher paths, cert/secret
189
- paths, private-overlay mode. Layered OVER spexcode.json (see MERGE below); an env
190
- 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.
191
196
 
192
197
  Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
193
198
  icon, a lint budget, a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
@@ -222,23 +227,24 @@ Example:
222
227
  Counts compute slots, not total sessions: idle/asking/review/done do not
223
228
  occupy one. A policy number → committed spexcode.json; omit it to use the
224
229
  default, or tune higher/lower for the project's usual host.
225
- sessions.claudeCmd command used by the built-in "claude" launcher (default
226
- 'claude --dangerously-skip-permissions'); env SPEXCODE_CLAUDE_CMD overrides.
227
- sessions.codexCmd command used by the built-in "codex" launcher (default 'codex --yolo'); env
228
- SPEXCODE_CODEX_CMD overrides.
229
- sessions.launchers additional 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.
230
232
  sessions.defaultLauncher the launcher name a create with no explicit --launcher/dropdown pick uses
231
233
  (required for no-choice creates). A portable NAME → committed.
232
- A claudeCmd/codexCmd or a launcher \`cmd\` that is a HOST-SPECIFIC ABSOLUTE PATH belongs in
233
- spexcode.local.json — the committed file must stay free of machine paths.
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.
234
236
 
235
237
  ── LAUNCHERS (the profile block, split across the two files) ──
236
238
  A named launcher profile fixes BOTH a session's harness AND its exact launch command; a create picks one
237
239
  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:
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:
242
248
  "launchers": { "<name>": { "harness": "claude" | "codex", "cmd": "<launch command>" } }
243
249
  \`harness\` defaults to "claude"; \`cmd\` is required. Because \`cmd\` is a machine fact (an abs wrapper path),
244
250
  the DEFINITION lives in the gitignored spexcode.local.json, while the portable defaultLauncher NAME sits
@@ -49,14 +49,15 @@ export interface Harness {
49
49
 
50
50
  // --- launch / sessionId ---
51
51
  // the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
52
- // visible TUI with `--remote` pointed at it. `cmd`, when given, is the SESSION's persisted launcher command
53
- // ([[launcher-select]]) and OVERRIDES the ambient env→config→default resolution so a session created under a
54
- // named launcher keeps that exact command (and auth) on resume, never silently reverting to the global
55
- // default. Omitted is only for tests and old records before launch_cmd was pinned.
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).
56
56
  launchCmd(id: string, runtimeDir?: string, cmd?: string): string
57
57
  // the RESOLVED base launcher command alone — the wrapper/binary that carries the agent's config-dir env
58
58
  // (claude `CLAUDE_CONFIG_DIR`, codex `CODEX_HOME`), WITHOUT the per-launch script built around it. `cmd`,
59
- // 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
60
61
  // at creation so a resume replays the EXACT launcher that created the conversation — never re-resolving
61
62
  // against a since-changed default, which would point `--resume` at the wrong config dir and lose the
62
63
  // transcript ([[launcher-select]], the resume-launcher-pin). launchCmd builds its invocation ON TOP of this.
@@ -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',
@@ -1043,36 +1046,28 @@ export function harnessById(id: string): Harness {
1043
1046
 
1044
1047
  // --- named launcher profiles ([[launcher-select]]) ----------------------------------------------------------
1045
1048
  // a launcher = a `{ harness, cmd }` pair in spexcode.json's `sessions.launchers`, keyed by a human-chosen name.
1046
- // harness defaults to claude. resolveLauncher throws fail-loud on an unknown name (a session must never
1047
- // 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.
1048
1053
  export type Launcher = { name: string; harness: string; cmd: string }
1049
1054
  export type LauncherDefault = { default: string | null; error: string | null }
1050
1055
 
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.
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.
1060
1058
  export function launcherList(root = mainCheckout()): Launcher[] {
1061
1059
  const m = readConfig(root).sessions?.launchers || {}
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))
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))
1068
1063
  }
1069
1064
 
1070
1065
  export const MISSING_DEFAULT_LAUNCHER_ERROR =
1071
1066
  'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})'
1072
1067
 
1073
1068
  // the configured default launcher NAME ([[launcher-select]]) — the profile `spex new`/a dropdown pick with no
1074
- // explicit choice resolves. Missing config is a fail-loud setup error, never an implicit fallthrough to the
1075
- // built-in `claude` launcher.
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).
1076
1071
  export function defaultLauncher(root = mainCheckout()): string {
1077
1072
  const name = readConfig(root).sessions?.defaultLauncher?.trim()
1078
1073
  if (!name) throw new Error(MISSING_DEFAULT_LAUNCHER_ERROR)
@@ -1091,9 +1086,9 @@ export function launcherDefault(root = mainCheckout()): LauncherDefault {
1091
1086
 
1092
1087
  export function resolveLauncher(name: string, root = mainCheckout()): Launcher {
1093
1088
  const l = readConfig(root).sessions?.launchers?.[name]
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'})`)
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 }
1097
1092
  harnessById(resolved.harness) // validate the harness id fail-loud
1098
1093
  return resolved
1099
1094
  }
@@ -111,7 +111,7 @@ only the trailer. If the intent DID change, edit the spec instead — same commi
111
111
  line: 'yatsu <sub> measure a node’s scenarios & file the loss signal: scan | eval | retract | show | clean',
112
112
  body: `Usage: spex yatsu scan [--changed] list nodes/scenarios missing readings
113
113
  spex yatsu eval [.|<node>] [--scenario <name>] (--pass|--fail) [--note <text>]
114
- [--image <png> …] [--result <path|->] [--video <webm|mp4> [--timeline <json>]]
114
+ [--image <png> …] [--result <path|->] [--video <webm|mp4>] [--timeline <json>]
115
115
  spex yatsu retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>]
116
116
  spex yatsu show [.|<node>] [--json] readings history for a node
117
117
  spex yatsu clean [--keep-latest | --all] prune stored readings
@@ -27,8 +27,6 @@ type Config = {
27
27
  }
28
28
  sessions?: {
29
29
  maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 8; see sessions.ts maxActive)
30
- claudeCmd?: string // the built-in `claude` launcher command (default 'claude --dangerously-skip-permissions'); env SPEXCODE_CLAUDE_CMD overrides. A host-specific ABS path belongs in the gitignored spexcode.local.json, not here.
31
- codexCmd?: string // the built-in `codex` launcher command (default 'codex --yolo'); env SPEXCODE_CODEX_CMD overrides. Same host-path rule.
32
30
  // named launcher profiles: a session picks ONE by name at create time ([[launcher-select]]), fixing both
33
31
  // its harness AND its exact launch command; the chosen NAME is persisted on the record so resume reuses the
34
32
  // same auth. `harness` defaults to 'claude'. Host-specific `cmd`s (abs wrapper paths) belong in the
@@ -78,7 +76,7 @@ export function readJsonConfig(p: string): any {
78
76
  // committed `spexcode.json` with an OPTIONAL machine-local `spexcode.local.json` layered on top (gitignored).
79
77
  // The local layer is the durable home for HOST-SPECIFIC values that must never be committed — e.g. an
80
78
  // absolute worker-launcher path (the host-path leak the repo otherwise warns against). Precedence per field:
81
- // local over committed; an env var (e.g. SPEXCODE_CLAUDE_CMD) still overrides both at its read site.
79
+ // local over committed; a targeted env override (e.g. SPEXCODE_CODEX_SERVER_CMD) still wins at its read site.
82
80
  export function readConfig(root: string): Config {
83
81
  const committed = readJsonConfig(join(root, 'spexcode.json'))
84
82
  const local = readJsonConfig(join(root, 'spexcode.local.json'))
@@ -32,9 +32,34 @@ export function loadConfig(root: string): LintConfig {
32
32
  // Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
33
33
  // silently reverting the author's budgets to defaults and green-washing the very warnings they tuned.
34
34
  const c = readJsonConfig(join(root, 'spexcode.json'))?.lint ?? {}
35
- return { ...DEFAULT_CONFIG, ...c, altitude: { ...DEFAULT_CONFIG.altitude, ...(c.altitude ?? {}) } }
35
+ const merged = { ...DEFAULT_CONFIG, ...c, altitude: { ...DEFAULT_CONFIG.altitude, ...(c.altitude ?? {}) } }
36
+ return normalizeConfig(merged)
36
37
  }
37
38
 
39
+ // canonicalize two adopter-input footguns that would otherwise SILENTLY match ZERO files (the same failure
40
+ // class as an unset governedRoots — a green board that governs nothing). Both are natural mistakes a non-web
41
+ // adopter makes reading the prose, so we accept-what-they-meant rather than reject:
42
+ // - a LEADING DOT on an extension: the matcher is `\.(ext)$`, so a literal ".ts" becomes `\..ts$` and never
43
+ // matches. Strip leading dots → ["ts"] and [".ts"] both work (prose historically showed ".ts").
44
+ // - a testGlob with NO "/": globs anchor to the full repo-relative path, so a bare "*.test.ts" matches only
45
+ // ROOT-level files and leaks every nested test into coverage. A slash-less glob is a basename intent →
46
+ // prepend "**/" so it matches that basename at any depth (the default "**/*.test.*" already does).
47
+ export function normalizeConfig(cfg: LintConfig): LintConfig {
48
+ const dedot = (xs: string[]) => xs.map((x) => x.replace(/^\.+/, ''))
49
+ return {
50
+ ...cfg,
51
+ sourceExtensions: dedot(cfg.sourceExtensions),
52
+ identifierExtensions: dedot(cfg.identifierExtensions),
53
+ testGlobs: cfg.testGlobs.map((g) => (g.includes('/') ? g : `**/${g}`)),
54
+ }
55
+ }
56
+
57
+ // the source-file matcher, built from the configurable `sourceExtensions` knob. Coverage uses it to decide
58
+ // which tracked files must be governed; yatsu's `yatsu-uncovered` reuses THE SAME knob so ONE setting
59
+ // defines "source" for both coverage axes — a non-web project (Rust/Go/Python .rs/.go/.py) sets it once and
60
+ // both the coverage warning and the loss-signal blind-spot check follow, with no second web-only allowlist.
61
+ export const sourceExtRe = (extensions: string[]) => new RegExp(`\\.(${extensions.join('|')})$`)
62
+
38
63
  // a minimal glob → RegExp anchored to the full repo-relative path: `**` = any dirs, `*` = within a segment.
39
64
  function globToRe(glob: string): RegExp {
40
65
  const body = glob.split(/(\*\*\/|\*\*|\*|\?)/).map((seg) => {
@@ -103,7 +128,7 @@ export async function specLint(): Promise<Finding[]> {
103
128
  const root = repoRoot()
104
129
  const cfg = loadConfig(root)
105
130
  const ident = identRe(cfg.identifierExtensions)
106
- const srcRe = new RegExp(`\\.(${cfg.sourceExtensions.join('|')})$`)
131
+ const srcRe = sourceExtRe(cfg.sourceExtensions)
107
132
  const specs = await loadSpecs()
108
133
  const out: Finding[] = []
109
134
 
@@ -159,10 +184,14 @@ export async function specLint(): Promise<Finding[]> {
159
184
 
160
185
  // coverage: every governed source file must be claimed by at least one spec.
161
186
  const governed = trackedSourceFiles(root, cfg.governedRoots, srcRe, cfg.testGlobs)
162
- // no governed source found at all → the defaults name this repo's own dirs, so an adopter who never set
163
- // lint.governedRoots would otherwise see a falsely-clean board. Make it loud and point at the knob.
187
+ // no governed source found at all → make it a SELF-EXPLANATORY repair entrypoint, not a dead end. The two
188
+ // knobs governing this are BOTH web-tuned by default (extensions ts/tsx/js/jsx; roots this repo's own dirs),
189
+ // so a non-web adopter (Rust/Go/Python) hits zero source two ways: right dir but wrong extension, or an
190
+ // unset root. Naming BOTH knobs, echoing their CURRENT values (so the mismatch is visible — "searching .ts
191
+ // in a .py tree"), and stating the `lint`-key nesting (a top-level key silently no-ops) turns the warning
192
+ // into the fix. Concrete non-web extension examples so the repair is copy-pasteable, not a schema hunt.
164
193
  if (governed.length === 0)
165
- out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — no source files under governedRoots [${cfg.governedRoots.join(', ')}]. Set lint.governedRoots in spexcode.json to your project's source dirs.` })
194
+ out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — 0 source files matched extensions [${cfg.sourceExtensions.join(', ')}] under governedRoots [${cfg.governedRoots.join(', ')}]. Both knobs live under the "lint" key in spexcode.json (a top-level key is ignored): set governedRoots to your source dir(s) (e.g. ["src"]) AND sourceExtensions to your language (e.g. ["rs"] / ["go"] / ["py"]).` })
166
195
  for (const f of governed)
167
196
  if (!claimed.has(f)) out.push({ level: 'warn', rule: 'coverage', file: f, msg: `no spec governs: ${f}` })
168
197
 
@@ -43,7 +43,8 @@ import { stripRefSigil } from './mentions.js'
43
43
  // deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
44
44
  //
45
45
  // Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
46
- // SPEXCODE_TMUX / SPEXCODE_CLAUDE_CMD override both for tests.
46
+ // SPEXCODE_TMUX overrides the tmux socket for tests; the launch COMMAND comes from the session's pinned
47
+ // launcher ([[launcher-select]]), not an env var.
47
48
 
48
49
  const pexec = promisify(execFile)
49
50
  export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
@@ -1001,12 +1002,13 @@ export async function assertProjectMatch(verb: string): Promise<void> {
1001
1002
  }
1002
1003
 
1003
1004
  // @@@ createSession (dispatch via backend) - `spex new` / `spex session new` must launch the worker in the
1004
- // BACKEND's process, not the caller's. The backend owns the launch env (notably SPEXCODE_CLAUDE_CMD, which
1005
- // reclaude strips from agent envs) AND the concurrency cap. An agent that runs `spex new` (e.g. a supervisor)
1006
- // has a stripped env, so an in-process launch would spawn workers under plain `claude` and 401 at boot. So
1007
- // the CLI POSTs to the running backend whenever one answers, making the backend the single owner of session
1008
- // launching. Only when NO backend is reachable do we fall back to launching in this process (with a stderr
1009
- // warning) the backend's own POST handler calls newSession directly, so it never re-enters this path.
1005
+ // BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
1006
+ // launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex new` (e.g. a supervisor) would
1007
+ // bypass that queue and the maxActive gate. (The launch COMMAND is not a process-env concern anymore it
1008
+ // comes from the session's pinned launcher, resolved from project config [[launcher-select]], identical in
1009
+ // either process.) So the CLI POSTs to the running backend whenever one answers. Only when NO backend is
1010
+ // reachable do we fall back to launching in this process (with a stderr warning) the backend's own POST
1011
+ // handler calls newSession directly, so it never re-enters this path.
1010
1012
  export async function createSession(node: string | null, prompt: string, launcher?: string): Promise<Session> {
1011
1013
  await assertProjectMatch('spex new')
1012
1014
  // @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
@@ -1266,6 +1268,7 @@ export type ReviewGates = {
1266
1268
  }
1267
1269
  export type ReviewPayload = {
1268
1270
  id: string; node: string | null; branch: string | null
1271
+ label: string // the session's identity, derived ONCE via deriveLabel — the review surface renders THIS, never its own node||branch||id chain
1269
1272
  ahead: number // commits the node branch is ahead of main
1270
1273
  dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
1271
1274
  diff: ReviewDiffFile[] // the worker's real changes, anchored at the merge-base
@@ -1332,6 +1335,7 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
1332
1335
  const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
1333
1336
  return {
1334
1337
  id, node: wt.rec.node, branch: wt.branch,
1338
+ label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
1335
1339
  ahead: Number(aheadOut.trim()) || 0,
1336
1340
  dirtyNonRuntime, diff,
1337
1341
  gates: { conflictsWithMain, lint },
@@ -1,5 +1,12 @@
1
1
  {
2
2
  "lint": {
3
3
  "governedRoots": ["."]
4
+ },
5
+ "sessions": {
6
+ "launchers": {
7
+ "claude": { "harness": "claude", "cmd": "claude --dangerously-skip-permissions" },
8
+ "codex": { "harness": "codex", "cmd": "codex --yolo" }
9
+ },
10
+ "defaultLauncher": "claude"
4
11
  }
5
12
  }