spexcode 0.2.2 → 0.2.4
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 +27 -16
- package/package.json +1 -1
- package/spec-cli/src/cli.ts +3 -2
- package/spec-cli/src/guide.ts +38 -32
- package/spec-cli/src/harness.ts +26 -31
- package/spec-cli/src/help.ts +1 -1
- package/spec-cli/src/layout.ts +1 -3
- package/spec-cli/src/lint.ts +34 -5
- package/spec-cli/src/sessions.ts +15 -7
- package/spec-cli/src/worktree-sources.ts +19 -0
- package/spec-cli/templates/spexcode.json +7 -0
- package/spec-dashboard/dist/assets/{Dashboard-BwZ2KzxB.js → Dashboard-Dlg78cbC.js} +3 -3
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
- package/spec-dashboard/dist/assets/{FoldToggle-GwE0-k1d.js → FoldToggle-B5leylLf.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-B17pnl9I.js → IssuesPage-C2yFXiO-.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-WEZbR8M1.js → MobileApp-RHNECU6x.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Sh8kHpnj.js → SessionInterface-YLD6IOmC.js} +1 -1
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-Dgtg-Xb9.js → Settings-ZnOwskMZ.js} +1 -1
- package/spec-dashboard/dist/assets/{index-Dd0_U5rk.js → index-BdRQfrkR.js} +2 -2
- package/spec-dashboard/dist/assets/{index-CCmnCbKS.css → index-DEc5Ru3l.css} +1 -1
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-yatsu/src/cli.ts +39 -11
- 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/sidecar.ts +25 -3
- package/spec-yatsu/src/timeline.ts +53 -23
- package/README.zh-CN.md +0 -170
- package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +0 -3
- package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +0 -9
package/README.md
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
|
-
<
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
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 ≥ 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
|
-
implementation from drifting apart.
|
|
17
|
+
implementation from drifting apart.
|
|
18
|
+
|
|
19
|
+
[](https://spexcode.net/assets/spec-tree-growth.mp4)
|
|
9
20
|
|
|
10
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.2.4",
|
|
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",
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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}` : ''}`)
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -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
|
|
98
|
-
.
|
|
99
|
-
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.
|
|
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
|
|
142
|
-
value the agent eyeballs off the finished artefact afterwards (that's
|
|
143
|
-
dishonest, worse than none).
|
|
144
|
-
|
|
145
|
-
(
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 (
|
|
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);
|
|
190
|
-
|
|
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.
|
|
226
|
-
|
|
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
|
|
233
|
-
|
|
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.
|
|
239
|
-
|
|
240
|
-
"
|
|
241
|
-
|
|
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
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -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
|
|
53
|
-
// ([[launcher-select]])
|
|
54
|
-
//
|
|
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,
|
|
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 =
|
|
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',
|
|
@@ -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
|
-
//
|
|
1047
|
-
//
|
|
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
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
|
1075
|
-
//
|
|
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 (
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
}
|
package/spec-cli/src/help.ts
CHANGED
|
@@ -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
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -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;
|
|
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'))
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 →
|
|
163
|
-
//
|
|
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 —
|
|
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
|
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
4
4
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
5
5
|
import { join, dirname, relative, isAbsolute } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { linkUntrackedSpecSources } from './worktree-sources.js'
|
|
7
8
|
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
|
|
8
9
|
import { loadSpecs } from './specs.js'
|
|
9
10
|
import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
@@ -43,7 +44,8 @@ import { stripRefSigil } from './mentions.js'
|
|
|
43
44
|
// deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
|
|
44
45
|
//
|
|
45
46
|
// Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
|
|
46
|
-
// SPEXCODE_TMUX
|
|
47
|
+
// SPEXCODE_TMUX overrides the tmux socket for tests; the launch COMMAND comes from the session's pinned
|
|
48
|
+
// launcher ([[launcher-select]]), not an env var.
|
|
47
49
|
|
|
48
50
|
const pexec = promisify(execFile)
|
|
49
51
|
export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
@@ -1001,12 +1003,13 @@ export async function assertProjectMatch(verb: string): Promise<void> {
|
|
|
1001
1003
|
}
|
|
1002
1004
|
|
|
1003
1005
|
// @@@ createSession (dispatch via backend) - `spex new` / `spex session new` must launch the worker in the
|
|
1004
|
-
// BACKEND's process, not the caller's
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1006
|
+
// BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
|
|
1007
|
+
// launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex new` (e.g. a supervisor) would
|
|
1008
|
+
// bypass that queue and the maxActive gate. (The launch COMMAND is not a process-env concern anymore — it
|
|
1009
|
+
// comes from the session's pinned launcher, resolved from project config [[launcher-select]], identical in
|
|
1010
|
+
// either process.) So the CLI POSTs to the running backend whenever one answers. Only when NO backend is
|
|
1011
|
+
// reachable do we fall back to launching in this process (with a stderr warning) — the backend's own POST
|
|
1012
|
+
// handler calls newSession directly, so it never re-enters this path.
|
|
1010
1013
|
export async function createSession(node: string | null, prompt: string, launcher?: string): Promise<Session> {
|
|
1011
1014
|
await assertProjectMatch('spex new')
|
|
1012
1015
|
// @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
|
|
@@ -1059,6 +1062,9 @@ export async function newSession(node: string | null, prompt: string, parent: st
|
|
|
1059
1062
|
const branch = `node/${slug}`
|
|
1060
1063
|
const path = join(mainRoot(), '.worktrees', slug)
|
|
1061
1064
|
await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
|
|
1065
|
+
// a private-overlay repo keeps the spec sources out of git, so the checkout alone leaves this worktree
|
|
1066
|
+
// spec-blind and hook-dead — link them from main ([[private-overlay]]; no-op on a default-mode repo).
|
|
1067
|
+
linkUntrackedSpecSources(mainRoot(), path)
|
|
1062
1068
|
// prepared but NOT launched: enters the queue as `queued`. drainQueue() below launches it at once when a
|
|
1063
1069
|
// slot is free, else it waits — durable as a global record (+ its worktree), so it survives a backend
|
|
1064
1070
|
// restart and is still findable. governed:true — this is a DASHBOARD/CLI-launched session, so it feeds the
|
|
@@ -1266,6 +1272,7 @@ export type ReviewGates = {
|
|
|
1266
1272
|
}
|
|
1267
1273
|
export type ReviewPayload = {
|
|
1268
1274
|
id: string; node: string | null; branch: string | null
|
|
1275
|
+
label: string // the session's identity, derived ONCE via deriveLabel — the review surface renders THIS, never its own node||branch||id chain
|
|
1269
1276
|
ahead: number // commits the node branch is ahead of main
|
|
1270
1277
|
dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
|
|
1271
1278
|
diff: ReviewDiffFile[] // the worker's real changes, anchored at the merge-base
|
|
@@ -1332,6 +1339,7 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
|
1332
1339
|
const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
|
|
1333
1340
|
return {
|
|
1334
1341
|
id, node: wt.rec.node, branch: wt.branch,
|
|
1342
|
+
label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
|
|
1335
1343
|
ahead: Number(aheadOut.trim()) || 0,
|
|
1336
1344
|
dirtyNonRuntime, diff,
|
|
1337
1345
|
gates: { conflictsWithMain, lint },
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { existsSync, symlinkSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// @@@ worktree-sources ([[private-overlay]]) - a fresh session worktree checks out only TRACKED content, and
|
|
5
|
+
// private mode keeps `.spec` + `spexcode.json` untracked (spexcode.local.json is untracked in BOTH modes) —
|
|
6
|
+
// so git alone hands a dispatched agent a worktree with NO spec tree: every hook handler script is absent
|
|
7
|
+
// (the dispatcher silently runs nothing), spex sees zero nodes, and the gate re-renders per event over empty
|
|
8
|
+
// config roots. Link those sources from the main checkout instead. On a default-mode repo the checkout
|
|
9
|
+
// already carries them, so each link guard no-ops — one mechanism, never a mode branch. A failure degrades
|
|
10
|
+
// that worker (no hooks, no specs), so it is reported, not swallowed.
|
|
11
|
+
export function linkUntrackedSpecSources(main: string, wt: string): void {
|
|
12
|
+
for (const f of ['.spec', 'spexcode.json', 'spexcode.local.json']) {
|
|
13
|
+
try {
|
|
14
|
+
if (existsSync(join(main, f)) && !existsSync(join(wt, f))) symlinkSync(join(main, f), join(wt, f))
|
|
15
|
+
} catch (e) {
|
|
16
|
+
console.error(`spexcode: could not link ${f} from ${main} into worktree ${wt} — that worker runs without it (${e})`)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -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
|
}
|