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.
- package/README.md +147 -103
- package/README.zh-CN.md +123 -88
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +24 -1
- package/spec-cli/src/attach.ts +50 -0
- package/spec-cli/src/cli.ts +214 -62
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/guide.ts +63 -11
- package/spec-cli/src/harness.ts +48 -19
- package/spec-cli/src/help.ts +137 -49
- package/spec-cli/src/index.ts +31 -11
- package/spec-cli/src/issues.ts +48 -21
- package/spec-cli/src/layout.ts +4 -4
- package/spec-cli/src/localIssues.ts +44 -60
- package/spec-cli/src/materialize.ts +4 -2
- package/spec-cli/src/mentions.ts +22 -1
- package/spec-cli/src/pty-bridge.ts +39 -4
- package/spec-cli/src/ranker.ts +31 -12
- package/spec-cli/src/search.bench.mjs +30 -7
- package/spec-cli/src/search.ts +39 -0
- package/spec-cli/src/sessions.ts +149 -62
- package/spec-cli/src/specs.ts +16 -4
- package/spec-cli/src/supervise.ts +30 -6
- package/spec-cli/src/tree.ts +118 -0
- package/spec-cli/templates/hooks/post-merge +2 -2
- package/spec-cli/templates/hooks/pre-commit +34 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
- package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
- package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
- package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
- package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-yatsu/src/cli.ts +89 -15
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// @@@ spex
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// `contract
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
341
|
-
|
|
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
|
|
350
|
-
switch (args[0]
|
|
351
|
-
case
|
|
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
|
|
358
|
+
default: console.error(`spex doctor: unknown subcommand "${args[0]}"`); usage(); return 2
|
|
358
359
|
}
|
|
359
360
|
}
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -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
|
|
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
|
|
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>]
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
(
|
|
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
|
|
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\`.
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -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
|
|
23
|
-
//
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
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.
|
|
1060
|
-
//
|
|
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
|
-
|
|
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 (
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
}
|