spexcode 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/README.md +4 -2
  2. package/package.json +1 -1
  3. package/spec-cli/src/cli.ts +9 -1
  4. package/spec-cli/src/guide.ts +23 -16
  5. package/spec-cli/src/harness.ts +18 -9
  6. package/spec-cli/src/help.ts +4 -1
  7. package/spec-cli/src/sessions.ts +34 -15
  8. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +1 -1
  9. package/spec-dashboard/dist/assets/{Dashboard-BlRRsxE7.js → Dashboard-P0B9ukSG.js} +3 -3
  10. package/spec-dashboard/dist/assets/{EvalsPage-BzVE38-Z.js → EvalsPage-BrvAGyc4.js} +1 -1
  11. package/spec-dashboard/dist/assets/{FoldToggle-DFuLVOeu.js → FoldToggle-BuQ0lokE.js} +1 -1
  12. package/spec-dashboard/dist/assets/{IssuesPage-CzDaazhe.js → IssuesPage-H-D8aHEl.js} +1 -1
  13. package/spec-dashboard/dist/assets/{MobileApp-CXQrQCNp.js → MobileApp-oZXIeCPb.js} +1 -1
  14. package/spec-dashboard/dist/assets/{SessionInterface-D1pUBl6q.js → SessionInterface-Blr_MEdU.js} +1 -1
  15. package/spec-dashboard/dist/assets/{SessionWindow-Y25Bwg1e.js → SessionWindow-LcCzBMU7.js} +2 -2
  16. package/spec-dashboard/dist/assets/{Settings-R610Vbzd.js → Settings-_yOye-In.js} +1 -1
  17. package/spec-dashboard/dist/assets/{index-BO0Zuweu.js → index-BhIslAau.js} +2 -2
  18. package/spec-dashboard/dist/index.html +1 -1
  19. package/spec-yatsu/src/cli.ts +3 -5
  20. package/spec-yatsu/src/evaltab.ts +3 -2
  21. package/spec-yatsu/src/filing.ts +3 -4
  22. package/spec-yatsu/src/freshness.ts +1 -3
  23. package/spec-yatsu/src/proof.ts +3 -2
  24. package/spec-yatsu/src/scenariofresh.ts +27 -5
  25. package/spec-yatsu/src/sidecar.ts +8 -8
  26. 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`. The worker reads the
114
- governing spec before touching code, makes the change, rewrites the spec body to match, commits
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.6",
3
+ "version": "0.2.7",
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",
@@ -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
- if ('backendDown' in r) { console.error(`spex wait: ${r.backendDown}`); process.exit(1) } // fail loud, not a false timeout
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') {
@@ -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, the scenario (the yatsu.md), or the evaluator moves since it was filed.
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 (no vote one behavior)
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 — the old render vote is retired; there
343
- is exactly one residence behavior, decided per KIND (and, for a contract file, by its live CONTENT).
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, the retired vote's homes) are forgotten by the same pass. \`spex uninstall\` is the empty
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.
@@ -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. A real claude is accepting connects; a stale file ECONNREFUSED (instant); an
183
- // absent file → ENOENT (instant). So the common cases cost no waiting; the short timeout only bounds the
184
- // pathological file-present-but-listener-wedged case (then treated as not-live). Never throws.
185
- export function rendezvousListening(id: string, timeoutMs = 800): Promise<boolean> {
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: boolean) => {
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(false), timeoutMs)
197
- try { c = createConnection({ path: rvSock(id) }) } catch { return done(false) }
198
- c.on('connect', () => done(true))
199
- c.on('error', () => done(false)) // ECONNREFUSED (stale file) / ENOENT (gone) both mean no live agent
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
@@ -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 needs no vote: materialized artifacts are never tracked — hidden via the per-clone .git/info/exclude, with
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
  },
@@ -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]]); `probeFailed` = the tmux
338
- // window probe itself FAILED (timed out under load), which is DISTINCT from "tmux up, no sessions" the former
339
- // means death is UNPROVEN so those rows read `unknown`, the latter is authoritative and reads `offline`.
340
- export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; sockets: Set<string> }
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 backlogsee
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 → not in the set, and codex ignores it anyway.
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
- ids.forEach((id, i) => { if (listening[i]) sockets.add(id) })
364
- return { probeFailed: false, windows, sockets }
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 and the live-listener set so a socket dying (claude exit) OR the probe flipping to
393
- // unknown pushes a board-changed immediately, not only on window churn.
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
- export type WatchOutcome = { reached: DisplayStatus } | { timedOut: true } | { gone: true } | { backendDown: string }
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 spec-node tasks and dispatch one worker per independent task (`spex new "<task>" --node <id>` — give each ONLY its task), 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:
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: