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