spexcode 0.2.6 → 0.2.8
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 +4 -2
- package/package.json +1 -1
- package/spec-cli/src/cli.ts +9 -1
- package/spec-cli/src/guide.ts +23 -16
- package/spec-cli/src/harness.ts +18 -9
- package/spec-cli/src/help.ts +4 -1
- package/spec-cli/src/sessions.ts +34 -15
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-BlRRsxE7.js → Dashboard-P0B9ukSG.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-BzVE38-Z.js → EvalsPage-BrvAGyc4.js} +1 -1
- package/spec-dashboard/dist/assets/{FoldToggle-DFuLVOeu.js → FoldToggle-BuQ0lokE.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-CzDaazhe.js → IssuesPage-H-D8aHEl.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-CXQrQCNp.js → MobileApp-oZXIeCPb.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-D1pUBl6q.js → SessionInterface-Blr_MEdU.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionWindow-Y25Bwg1e.js → SessionWindow-LcCzBMU7.js} +2 -2
- package/spec-dashboard/dist/assets/{Settings-R610Vbzd.js → Settings-_yOye-In.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BO0Zuweu.js → index-BhIslAau.js} +2 -2
- package/spec-dashboard/dist/index.html +1 -1
- package/spec-yatsu/src/cli.ts +3 -5
- package/spec-yatsu/src/evaltab.ts +3 -2
- package/spec-yatsu/src/filing.ts +3 -4
- package/spec-yatsu/src/freshness.ts +7 -5
- package/spec-yatsu/src/proof.ts +3 -2
- package/spec-yatsu/src/scenariofresh.ts +27 -5
- package/spec-yatsu/src/sidecar.ts +8 -8
- package/spec-yatsu/src/evaluator.ts +0 -24
package/README.md
CHANGED
|
@@ -110,8 +110,10 @@ on the machine.
|
|
|
110
110
|
spex new "make the settings page remember the last tab" --node settings
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
-
launches a worker session in its own worktree on branch `node/settings
|
|
114
|
-
|
|
113
|
+
launches a worker session in its own worktree on branch `node/settings-…`. The `--node` flag (or a
|
|
114
|
+
`[[settings]]` mention in the prompt, same effect) sets the branch name and board attribution; the
|
|
115
|
+
worker still finds and reads the governing spec itself before touching code. It makes the change,
|
|
116
|
+
rewrites the spec body to match, commits
|
|
115
117
|
both (a hook stamps the `Session:` trailer), then proposes a merge and stops. Workers never merge
|
|
116
118
|
themselves. The merge stays with the manager: when you fire it, the session's own agent runs the
|
|
117
119
|
actual `git merge`, so conflicts land on the one who knows the work. The same dispatch is a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
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
|
@@ -510,7 +510,15 @@ if (cmd === 'serve') {
|
|
|
510
510
|
}))
|
|
511
511
|
if ('reached' in r) { console.log(r.reached); process.exit(0) }
|
|
512
512
|
if ('gone' in r) { console.error(`spex wait: no such (living) session ${id}`); process.exit(2) }
|
|
513
|
-
|
|
513
|
+
// a backend failure is a verdict about the TRANSPORT, never the session ([[graph]], issue #40): it prints
|
|
514
|
+
// its own outcome token on stdout — a word OUTSIDE the session-status vocabulary, so a supervisor reading
|
|
515
|
+
// the one status line can never mistake "I could not reach the board" for "the session is offline" — and
|
|
516
|
+
// exits 3, distinct from the plain no-actionable-status timeout (1) and the vanished target (2).
|
|
517
|
+
if ('backendDown' in r) {
|
|
518
|
+
console.error(`spex wait: ${r.backendDown}`)
|
|
519
|
+
console.log(r.kind === 'unreachable' ? 'backend-unreachable' : 'backend-error')
|
|
520
|
+
process.exit(3)
|
|
521
|
+
}
|
|
514
522
|
console.error(`spex wait: timeout — ${id} did not reach an actionable status within ${timeoutSec}s`)
|
|
515
523
|
process.exit(1)
|
|
516
524
|
} else if (cmd === 'new') {
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -176,7 +176,7 @@ reading (\`--last\` makes that explicit; repeat to peel junk back one filing at
|
|
|
176
176
|
|
|
177
177
|
THE SCOREBOARD: readings live in yatsu.evals.ndjson beside the yatsu.md — one JSON line per measurement
|
|
178
178
|
(a second git-as-database axis). Freshness is derived live from git: a reading goes STALE when a governed
|
|
179
|
-
code file
|
|
179
|
+
code file or the scenario (the yatsu.md) moves since it was filed.
|
|
180
180
|
spex yatsu scan [--changed] blind spots: yatsu-schema (malformed) · yatsu-drift (stale) ·
|
|
181
181
|
yatsu-missing (never measured) · yatsu-uncovered (governed source, no yatsu.md) ·
|
|
182
182
|
yatsu-owners (a file governed by > maxOwners scenarios — split it)
|
|
@@ -326,21 +326,16 @@ Example — govern your own source dir and loosen the altitude budget:
|
|
|
326
326
|
{ "plugin": "<folder>" } bundle. Default (omitted): all native harnesses. PERSISTENT and
|
|
327
327
|
git-transactional: the edit takes effect at the next git-native materialize anchor (the commit
|
|
328
328
|
that carries it, a checkout/merge that receives it, or a manual \`spex materialize\`) — a
|
|
329
|
-
deselected harness's artifacts are pruned by that pass
|
|
330
|
-
render RETIRED (the old three-word footprint vote) — ignored with a loud notice. Materialized
|
|
331
|
-
artifacts are never tracked; there is exactly one residence behavior. Remove the field.
|
|
332
|
-
\`spex guide footprint\`.
|
|
333
|
-
private RETIRED (old private-overlay toggle) — ignored with a loud notice, same as \`render\`; its
|
|
334
|
-
data-untrack semantics are gone. See \`spex guide footprint\` MIGRATIONS.`
|
|
329
|
+
deselected harness's artifacts are pruned by that pass.`
|
|
335
330
|
|
|
336
|
-
const FOOTPRINT = `spex guide footprint — what SpexCode plants in a repo, and who sees it (
|
|
331
|
+
const FOOTPRINT = `spex guide footprint — what SpexCode plants in a repo, and who sees it (one fixed behavior per kind)
|
|
337
332
|
|
|
338
333
|
SpexCode claims software engineering's HEAD (the recording of intent) and TAIL (the storage of
|
|
339
334
|
measurement) and leaves the MIDDLE — construction — to the harness/agent/test framework; freshness
|
|
340
335
|
stitches the two ends into a closed loop. The footprint follows: the head+tail (.spec, spexcode.json,
|
|
341
336
|
readings) is the ASSET and lives in git like source; everything else is derived wiring or a machine fact.
|
|
342
|
-
Materialized artifacts carry no facts, so they are NEVER tracked —
|
|
343
|
-
|
|
337
|
+
Materialized artifacts carry no facts, so they are NEVER tracked — there is exactly one residence
|
|
338
|
+
behavior, decided per KIND (and, for a contract file, by its live CONTENT).
|
|
344
339
|
|
|
345
340
|
── THE FOUR KINDS (all fixed) ──
|
|
346
341
|
spec data .spec/ (incl .config/) + spexcode.json — ALWAYS tracked. Git is the database; there is
|
|
@@ -377,7 +372,7 @@ TRACK ≠ PUSH: none of this ever touches remotes; where commits GO is branch/re
|
|
|
377
372
|
── GUARANTEES (the forgetting law) ──
|
|
378
373
|
materialize(P₂) ∘ materialize(P₁) = materialize(P₂): every materialize first ERASES all landing points by
|
|
379
374
|
SpexCode's own identity stamps, then re-asserts — legacy states (a .gitignore managed block, a committed
|
|
380
|
-
artifact
|
|
375
|
+
artifact) are forgotten by the same pass. \`spex uninstall\` is the empty
|
|
381
376
|
materialize plus the global store: a total backout that never touches your .spec/.config or prose. Fresh
|
|
382
377
|
clones and session worktrees are self-sufficient: data by checkout, materialized artifacts by
|
|
383
378
|
re-materialize, the machine snapshot (spexcode.local.json) by copy.
|
|
@@ -389,12 +384,24 @@ the sentinel block (history never sees it); smudge re-injects it on checkout. A
|
|
|
389
384
|
identity (never a git fatal). Your own edits to the prose still show as real modifications; only the
|
|
390
385
|
block is invisible to git.
|
|
391
386
|
|
|
387
|
+
── PRIVATE LOCAL NODES (manual posture — works today) ──
|
|
388
|
+
Spec data is always tracked, but tracked WHERE is yours to choose: to keep a node off the shared
|
|
389
|
+
remote, give it a different git HOME instead of untracking it. The manual recipe:
|
|
390
|
+
1. create the node dir under .spec/ as usual (spec.md and friends);
|
|
391
|
+
2. add the dir's path to .git/info/exclude — per-clone, so the shared repo never sees it;
|
|
392
|
+
3. inside the dir, \`git init --separate-git-dir\` pointing somewhere under the main repo's .git/
|
|
393
|
+
(e.g. .git/spexcode/<name>.git) — the dir then holds only a one-line .git pointer file, so the
|
|
394
|
+
spec loader never walks an object store;
|
|
395
|
+
4. commit the node's changes through that inner repo.
|
|
396
|
+
The effect, honestly: filesystem-derived surfaces see the node (board, search, lint); git-derived
|
|
397
|
+
views are blind to it (version count, the history tab, drift), and a dispatched worker's worktree
|
|
398
|
+
checkout does not contain it. Those gaps are what the pending spec-local design (a first-class
|
|
399
|
+
private overlay root) closes — not built yet. Cautions: \`git clean -fdx\` in the outer repo deletes
|
|
400
|
+
the inner repo along with the dir, so off-machine backup means giving the INNER repo its own private
|
|
401
|
+
remote; and taking the node public later is a migration (move it into the shared tree and commit),
|
|
402
|
+
not a flag flip.
|
|
403
|
+
|
|
392
404
|
── MIGRATIONS ──
|
|
393
|
-
render: "..." (retired) remove the field from spexcode.json / spexcode.local.json — it is ignored
|
|
394
|
-
with a loud notice. A previously COMMITTED artifact heals at your next commit
|
|
395
|
-
touching it (pre-commit strips the block from the staged blob); or run
|
|
396
|
-
\`spex materialize\` + commit the block's removal once.
|
|
397
|
-
private: true (retired) same — remove it; its data-untrack semantics are long gone.
|
|
398
405
|
legacy untracked spec track the sources once: git add .spec spexcode.json (commit on your branch)
|
|
399
406
|
WARNING: tracking is not retroactive secrecy — history already pushed
|
|
400
407
|
elsewhere cannot be recalled.
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -179,24 +179,33 @@ export const rvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
|
|
|
179
179
|
// A crashed/killed claude can leave its rvSock FILE on disk (a unix-domain socket path is NOT auto-unlinked on
|
|
180
180
|
// an unclean exit), so the old `existsSync(rvSock)` read a DEAD pane as `online` for as long as the stale file
|
|
181
181
|
// lingered — the incident's "dead pane stuck `working` for 30+ min". The honest signal is a live LISTENER:
|
|
182
|
-
// connect() to the socket.
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
182
|
+
// connect() to the socket. The verdict is TRI-STATE, because only two probe results actually PROVE anything:
|
|
183
|
+
// 'live' — the connect completed: a real claude is accepting.
|
|
184
|
+
// 'dead' — ECONNREFUSED (a stale file nothing listens on) / ENOENT (no file): death PROVEN, instantly.
|
|
185
|
+
// 'unproven' — the probe itself failed to conclude: a TIMEOUT (under load the prober's event loop fires the
|
|
186
|
+
// expired timer before the pending connect event — the thrashed-backend incident where every live worker
|
|
187
|
+
// read offline in one board answer), or EAGAIN (the listen backlog is FULL, which proves a listener is
|
|
188
|
+
// alive-but-busy, the opposite of dead). Collapsing these into 'dead' is how a load spike masqueraded as
|
|
189
|
+
// a graveyard (issue #40); the caller must render unproven death as `unknown`, never `offline`.
|
|
190
|
+
// The common cases cost no waiting (connect/refuse/absent are instant); the short timeout only bounds the
|
|
191
|
+
// wedged/thrashed path. Never throws.
|
|
192
|
+
export type ListenerProbe = 'live' | 'dead' | 'unproven'
|
|
193
|
+
const PROVEN_DEAD = new Set(['ECONNREFUSED', 'ENOENT'])
|
|
194
|
+
export function rendezvousListening(id: string, timeoutMs = 800): Promise<ListenerProbe> {
|
|
186
195
|
return new Promise((resolve) => {
|
|
187
196
|
let settled = false
|
|
188
197
|
let c: ReturnType<typeof createConnection> | undefined
|
|
189
|
-
const done = (v:
|
|
198
|
+
const done = (v: ListenerProbe) => {
|
|
190
199
|
if (settled) return
|
|
191
200
|
settled = true
|
|
192
201
|
clearTimeout(timer)
|
|
193
202
|
try { c?.destroy() } catch { /* */ }
|
|
194
203
|
resolve(v)
|
|
195
204
|
}
|
|
196
|
-
const timer = setTimeout(() => done(
|
|
197
|
-
try { c = createConnection({ path: rvSock(id) }) } catch { return done(
|
|
198
|
-
c.on('connect', () => done(
|
|
199
|
-
c.on('error', () => done(
|
|
205
|
+
const timer = setTimeout(() => done('unproven'), timeoutMs)
|
|
206
|
+
try { c = createConnection({ path: rvSock(id) }) } catch { return done('unproven') }
|
|
207
|
+
c.on('connect', () => done('live'))
|
|
208
|
+
c.on('error', (e) => done(PROVEN_DEAD.has((e as NodeJS.ErrnoException).code ?? '') ? 'dead' : 'unproven'))
|
|
200
209
|
})
|
|
201
210
|
}
|
|
202
211
|
// The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
|
package/spec-cli/src/help.ts
CHANGED
|
@@ -187,6 +187,9 @@ a node/<id> branch links its PR for free). Read-only — git/.spec stays the sin
|
|
|
187
187
|
|
|
188
188
|
Creates a session: node branch + worktree + a launched agent carrying your prompt (= session new).
|
|
189
189
|
Give it ONLY its task — the dev-flow contract reaches it through the materialized system prompt.
|
|
190
|
+
--node <id> (or the prompt's first [[id]] mention, same effect) binds the session to that node:
|
|
191
|
+
branch name node/<id>-<shortid>, board attribution, and one appended line with the node's spec.md
|
|
192
|
+
path when the node exists. With neither, the session is bound to no node.
|
|
190
193
|
--prompt-file <path> reads the prompt from a file (- = stdin), so a long multi-paragraph prompt never
|
|
191
194
|
fights shell quoting; it is exclusive with the inline prompt (both given = error), and an unreadable
|
|
192
195
|
or empty file refuses the launch.
|
|
@@ -319,7 +322,7 @@ ${ROUTING_NOTE}`,
|
|
|
319
322
|
Scaffolds adoption in one shot: seeds a starter .spec tree (project root + .config plugins), plants
|
|
320
323
|
spexcode.json, installs the git hooks, and materializes the harness artifacts (contract block +
|
|
321
324
|
shims). Additive — never overwrites your files. --preset picks the .config plugin tier (cumulative).
|
|
322
|
-
Footprint
|
|
325
|
+
Footprint is fixed: materialized artifacts are never tracked — hidden via the per-clone .git/info/exclude, with
|
|
323
326
|
a tracked/mixed CLAUDE.md/AGENTS.md covered by the clean/smudge filter (see spex guide footprint).`,
|
|
324
327
|
see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex lint (adoption TODO)',
|
|
325
328
|
},
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -334,10 +334,13 @@ async function procSnapshot(): Promise<ProcTable> {
|
|
|
334
334
|
}
|
|
335
335
|
// @@@ LiveSnap - the ONE liveness snapshot the whole session list shares. `windows` = our live tmux windows
|
|
336
336
|
// (id → PaneProbe) + one whole-box process table; `sockets` = the ids whose rendezvous socket has a LIVE
|
|
337
|
-
// LISTENER (connect-probed once here, not the file-exists lie — [[harness-adapter]]); `
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
337
|
+
// LISTENER (connect-probed once here, not the file-exists lie — [[harness-adapter]]); `unproven` = the ids whose
|
|
338
|
+
// LISTENER probe could not conclude (timeout under load / EAGAIN off a full-but-alive backlog — see
|
|
339
|
+
// rendezvousListening's tri-state) — death UNPROVEN, so those rows read `unknown`, never `offline`;
|
|
340
|
+
// `probeFailed` = the tmux window probe itself FAILED (timed out under load), which is DISTINCT from "tmux up,
|
|
341
|
+
// no sessions" — the former means death is UNPROVEN so those rows read `unknown`, the latter is authoritative
|
|
342
|
+
// and reads `offline`.
|
|
343
|
+
export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; sockets: Set<string>; unproven: Set<string> }
|
|
341
344
|
async function liveSnapshot(): Promise<LiveSnap> {
|
|
342
345
|
const windows = new Map<string, PaneProbe>()
|
|
343
346
|
let out: string
|
|
@@ -346,7 +349,7 @@ async function liveSnapshot(): Promise<LiveSnap> {
|
|
|
346
349
|
} catch (e) {
|
|
347
350
|
// a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
|
|
348
351
|
// non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
|
|
349
|
-
return { probeFailed: probeTimedOut(e), windows, sockets: new Set() }
|
|
352
|
+
return { probeFailed: probeTimedOut(e), windows, sockets: new Set(), unproven: new Set() }
|
|
350
353
|
}
|
|
351
354
|
const procs = await procSnapshot().catch(() => undefined) // codex-only, auxiliary; its failure isn't a liveness failure
|
|
352
355
|
for (const line of out.split('\n')) {
|
|
@@ -356,12 +359,19 @@ async function liveSnapshot(): Promise<LiveSnap> {
|
|
|
356
359
|
windows.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, procs })
|
|
357
360
|
}
|
|
358
361
|
// LISTENER probe for every windowed session, once, in parallel (tooth: a live listener, not a lingering
|
|
359
|
-
// socket file). A codex session has no rvSock → instant ENOENT →
|
|
362
|
+
// socket file). A codex session has no rvSock → instant ENOENT → proven dead for the socket axis (codex
|
|
363
|
+
// ignores it anyway). The tri-state matters here: 'unproven' (timeout/EAGAIN — a wedged or thrashed but
|
|
364
|
+
// possibly-alive listener) lands in `unproven`, never silently in the not-live bucket, so liveness() can
|
|
365
|
+
// render it `unknown` instead of a false `offline` (issue #40's load-spike graveyard).
|
|
360
366
|
const ids = [...windows.keys()]
|
|
361
367
|
const listening = await Promise.all(ids.map((id) => rendezvousListening(id)))
|
|
362
368
|
const sockets = new Set<string>()
|
|
363
|
-
|
|
364
|
-
|
|
369
|
+
const unproven = new Set<string>()
|
|
370
|
+
ids.forEach((id, i) => {
|
|
371
|
+
if (listening[i] === 'live') sockets.add(id)
|
|
372
|
+
else if (listening[i] === 'unproven') unproven.add(id)
|
|
373
|
+
})
|
|
374
|
+
return { probeFailed: false, windows, sockets, unproven }
|
|
365
375
|
}
|
|
366
376
|
|
|
367
377
|
// @@@ paneTitles - every session pane's RAW tmux title, free from tmux. The worker launches one pane per
|
|
@@ -389,10 +399,11 @@ async function paneTitles(): Promise<Map<string, string>> {
|
|
|
389
399
|
// dashboard waiting for its slow cold-path fallback. Sorted so it only moves on a real change.
|
|
390
400
|
export async function sessionSignature(): Promise<string> {
|
|
391
401
|
const [snap, titles] = await Promise.all([liveSnapshot(), paneTitles()])
|
|
392
|
-
// fold in probe-failure
|
|
393
|
-
// unknown pushes a board-changed immediately, not only
|
|
402
|
+
// fold in probe-failure, the live-listener set AND the unproven set so a socket dying (claude exit), the
|
|
403
|
+
// probe flipping to unknown, or a listener wedging (unproven) pushes a board-changed immediately, not only
|
|
404
|
+
// on window churn.
|
|
394
405
|
return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
|
|
395
|
-
[...snap.sockets].sort().join(',') + '|' + [...titles].sort().map(([k, v]) => `${k}=${v}`).join(',')
|
|
406
|
+
[...snap.sockets].sort().join(',') + '~' + [...snap.unproven].sort().join(',') + '|' + [...titles].sort().map(([k, v]) => `${k}=${v}`).join(',')
|
|
396
407
|
}
|
|
397
408
|
|
|
398
409
|
// @@@ paneActivity - the harness-aware live self-summary: the SINGLE place a raw pane title becomes (or does
|
|
@@ -463,6 +474,10 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
|
463
474
|
// online-signal hasn't appeared yet reads 'starting' for the boot window, only past it 'offline'.
|
|
464
475
|
const h = harnessById(rec.harness || defaultHarness.id)
|
|
465
476
|
if (h.liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online') return 'online'
|
|
477
|
+
// not provably online — but if this session's LISTENER probe couldn't conclude (timeout under load / EAGAIN
|
|
478
|
+
// off a full-but-alive backlog), death is UNPROVEN: `unknown`, never a false `offline` a supervisor would
|
|
479
|
+
// act on (issue #40 — a wedged-but-alive worker must not read as an actionable corpse).
|
|
480
|
+
if (snap.unproven.has(rec.session)) return 'unknown'
|
|
466
481
|
const at = launchedAt.get(rec.session)
|
|
467
482
|
return at && Date.now() - at < BOOT_GRACE_MS ? 'starting' : 'offline'
|
|
468
483
|
}
|
|
@@ -1647,8 +1662,12 @@ export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]
|
|
|
1647
1662
|
// "block for a worker, then exit" that is GUARANTEED to return. The deadline is checked EVERY poll, before
|
|
1648
1663
|
// EVERY sleep (and even when a poll throws), so a target stuck in ANY non-actionable state
|
|
1649
1664
|
// (`working`/`parked`/`idle`/`queued`/`starting`) can never hang the caller — it exits at the deadline.
|
|
1650
|
-
// `reached` = the target hit an actionable status; the rest are the loud exits.
|
|
1651
|
-
|
|
1665
|
+
// `reached` = the target hit an actionable status; the rest are the loud exits. `backendDown` is a verdict
|
|
1666
|
+
// about the TRANSPORT, never the session — `kind` keeps its two shapes distinct for the caller's outcome
|
|
1667
|
+
// surface: 'unreachable' (nothing listening, the whole timeout was spent retrying) vs 'http' (reachable but
|
|
1668
|
+
// broken, failed loud at once). The caller must surface these OUTSIDE the session-status vocabulary — a
|
|
1669
|
+
// supervisor must never be able to read a transport failure as a session state (issue #40).
|
|
1670
|
+
export type WatchOutcome = { reached: DisplayStatus } | { timedOut: true } | { gone: true } | { backendDown: string; kind: 'unreachable' | 'http' }
|
|
1652
1671
|
export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
|
|
1653
1672
|
const { source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
|
|
1654
1673
|
const tag = as ? `[${as}] ` : ''
|
|
@@ -1702,7 +1721,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1702
1721
|
// is rebooting its hot-reloaded child behind the stable port on a sibling merge. This is TRANSIENT:
|
|
1703
1722
|
// record it, warn ONCE, and keep polling — the deadline (below) is the only hard wall, so a
|
|
1704
1723
|
// backgrounded `spex wait` survives the ~1s restart instead of dying on the interrupted fetch.
|
|
1705
|
-
if (until && isBackendDown(e) && !isBackendUnreachable(e)) return { backendDown: (e as Error).message }
|
|
1724
|
+
if (until && isBackendDown(e) && !isBackendUnreachable(e)) return { backendDown: (e as Error).message, kind: 'http' }
|
|
1706
1725
|
if (isBackendDown(e)) {
|
|
1707
1726
|
downMsg = (e as Error).message
|
|
1708
1727
|
if (!warnedDown) { warnedDown = true; console.error(`${tag}[spex] watch: ${downMsg}; retrying every ${intervalMs / 1000}s…`) }
|
|
@@ -1712,7 +1731,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1712
1731
|
// guarantees `spex wait` can never hang on a worker stuck outside WATCH_ACTIONABLE — nor spin forever on a
|
|
1713
1732
|
// backend that never comes back. Hitting the deadline while still unreachable reports THAT (`backendDown`),
|
|
1714
1733
|
// not a false "no actionable status" timeout, so the manager sees the honest cause.
|
|
1715
|
-
if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg } : { timedOut: true }
|
|
1734
|
+
if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg, kind: 'unreachable' } : { timedOut: true }
|
|
1716
1735
|
await sleep(intervalMs)
|
|
1717
1736
|
}
|
|
1718
1737
|
}
|
|
@@ -5,4 +5,4 @@ status: active
|
|
|
5
5
|
hue: 280
|
|
6
6
|
desc: Launch a supervisor agent that manages other agents from the main checkout to drive a goal to completion.
|
|
7
7
|
---
|
|
8
|
-
You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code. **FIRST, read `<root>/CLAUDE.md` — specifically its "Supervising — the manager loop" section** — that is your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize). Then drive the goal: decompose it into
|
|
8
|
+
You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code. **FIRST, read `<root>/CLAUDE.md` — specifically its "Supervising — the manager loop" section** — that is your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize). Then drive the goal: decompose it into worker-sized tasks and dispatch one worker per independent task (`spex new "<task>"` — give each ONLY its task; a task about one specific node mentions it as `[[<id>]]`, which only sets the branch name and board attribution; the session's real node links come from what it edits), monitor with `spex watch`, review proposals with `spex review <id>`, merge good ones with `git -C <root> merge --no-ff <branch>`, then close. Never let a worker self-merge; keep `spex lint` at 0 errors. To WAIT on a worker, POLL one-shot (`spex review <id>` or `spex ls` — both return immediately); never block on `spex watch`, which STREAMS forever and will freeze your turn. One footgun that bites a fresh supervisor: before `spex session close <id>`, confirm the merge landed (`git -C <root> log -1` shows HEAD at the new merge commit) — closing an unmerged branch discards the work. Report progress as you go and when the goal is complete. Your goal follows:
|