spexcode 0.2.5 → 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 (45) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/dispatch.sh +28 -40
  4. package/spec-cli/hooks/harness.sh +29 -19
  5. package/spec-cli/src/cli.ts +30 -12
  6. package/spec-cli/src/commit-surgery.ts +81 -0
  7. package/spec-cli/src/contract-filter.ts +16 -14
  8. package/spec-cli/src/doctor.ts +11 -5
  9. package/spec-cli/src/guide.ts +69 -59
  10. package/spec-cli/src/harness-select.ts +2 -2
  11. package/spec-cli/src/harness.ts +23 -14
  12. package/spec-cli/src/help.ts +23 -17
  13. package/spec-cli/src/init.ts +5 -40
  14. package/spec-cli/src/layout.ts +26 -20
  15. package/spec-cli/src/materialize.ts +102 -126
  16. package/spec-cli/src/mentions.ts +5 -3
  17. package/spec-cli/src/plugin-harness.ts +10 -9
  18. package/spec-cli/src/sessions.ts +56 -28
  19. package/spec-cli/src/specs.ts +9 -4
  20. package/spec-cli/src/uninstall.ts +6 -5
  21. package/spec-cli/src/worktree-sources.ts +6 -4
  22. package/spec-cli/templates/hooks/post-checkout +22 -0
  23. package/spec-cli/templates/hooks/post-merge +15 -9
  24. package/spec-cli/templates/hooks/pre-commit +10 -0
  25. package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
  26. package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
  27. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +1 -1
  28. package/spec-dashboard/dist/assets/{Dashboard-CMRJGfYI.js → Dashboard-P0B9ukSG.js} +7 -7
  29. package/spec-dashboard/dist/assets/{EvalsPage-Dj2mxcfW.js → EvalsPage-BrvAGyc4.js} +1 -1
  30. package/spec-dashboard/dist/assets/{FoldToggle-BfNpeyRQ.js → FoldToggle-BuQ0lokE.js} +1 -1
  31. package/spec-dashboard/dist/assets/{IssuesPage-DfY315kt.js → IssuesPage-H-D8aHEl.js} +1 -1
  32. package/spec-dashboard/dist/assets/{MobileApp-BGdC0A0P.js → MobileApp-oZXIeCPb.js} +1 -1
  33. package/spec-dashboard/dist/assets/{SessionInterface-BOBCAR0t.js → SessionInterface-Blr_MEdU.js} +8 -8
  34. package/spec-dashboard/dist/assets/{SessionWindow-D7YmjV0i.js → SessionWindow-LcCzBMU7.js} +5 -5
  35. package/spec-dashboard/dist/assets/{Settings-BlSNmpH_.js → Settings-_yOye-In.js} +1 -1
  36. package/spec-dashboard/dist/assets/{index-BFdzpd_O.js → index-BhIslAau.js} +2 -2
  37. package/spec-dashboard/dist/index.html +1 -1
  38. package/spec-yatsu/src/cli.ts +3 -5
  39. package/spec-yatsu/src/evaltab.ts +3 -2
  40. package/spec-yatsu/src/filing.ts +3 -4
  41. package/spec-yatsu/src/freshness.ts +1 -3
  42. package/spec-yatsu/src/proof.ts +3 -2
  43. package/spec-yatsu/src/scenariofresh.ts +27 -5
  44. package/spec-yatsu/src/sidecar.ts +8 -8
  45. package/spec-yatsu/src/evaluator.ts +0 -24
@@ -9,7 +9,7 @@ import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type Review
9
9
  import { loadSpecs } from './specs.js'
10
10
  import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
11
11
  import { materialize } from './materialize.js'
12
- import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
12
+ import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
13
13
  import { stripRefSigil } from './mentions.js'
14
14
 
15
15
  // @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
@@ -76,7 +76,7 @@ function maxActive(): number {
76
76
  // rvSock is imported only for the two NON-delivery uses that remain product-level: building the launch env var
77
77
  // (rvEnv, below) and the best-effort socket sweep on close.
78
78
  // env prefix put in front of the spawned agent so it creates this session's rendezvous control socket — and
79
- // so its hooks + materialize render to the SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
79
+ // so its hooks + materialize write to the SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
80
80
  // propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
81
81
  // an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
82
82
  // ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
@@ -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
  }
@@ -817,14 +832,17 @@ export const slugify = (s: string | null) =>
817
832
  // When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
818
833
  // The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
819
834
  // (`.config`) keeps the dot — without `\.?` here `[[.config]]` captures nothing and never resolves to a node.
820
- const MENTION = /\[\[(\.?[A-Za-z0-9_-]+)\]\]/
835
+ // Token chars are ANY unicode letter/number (slugify's already-made choice): a CJK dir name is a legal node
836
+ // id, so `[[中文节点]]` must bind the session exactly like an ASCII id — ASCII-only here silently launched
837
+ // node-agnostic.
838
+ const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u
821
839
  const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
822
840
  // @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
823
841
  // prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
824
842
  // board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
825
843
  // it from under itself. Strip both before deriving; whatever prose remains names the session.
826
844
  const UUID_TOKEN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g
827
- const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[A-Za-z0-9_-]+/g, '$1').replace(UUID_TOKEN, ' ')
845
+ const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[\p{L}\p{N}_-]+/gu, '$1').replace(UUID_TOKEN, ' ')
828
846
  export function titleFromPrompt(prompt: string): string | null {
829
847
  const first = stripIdentityTokens(prompt || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
830
848
  const words = first.split(/\s+/).filter(Boolean).slice(0, 7).join(' ')
@@ -1074,8 +1092,9 @@ export async function newSession(node: string | null, prompt: string, parent: st
1074
1092
  const branch = `node/${slug}`
1075
1093
  const path = join(mainRoot(), '.worktrees', slug)
1076
1094
  await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
1077
- // the checkout delivers the tracked spec sources and the materialize below delivers the renders; the ONE
1078
- // thing git cannot carry is the machine-local spexcode.local.json — copied as a snapshot ([[render-policy]];
1095
+ // the checkout delivers the tracked spec sources and the materialize below delivers the materialized
1096
+ // artifacts; the ONE
1097
+ // thing git cannot carry is the machine-local spexcode.local.json — copied as a snapshot ([[residence]];
1079
1098
  // no-op when the main checkout has none).
1080
1099
  seedWorktreeHostState(mainRoot(), path)
1081
1100
  // prepared but NOT launched: enters the queue as `queued`. drainQueue() below launches it at once when a
@@ -1097,7 +1116,7 @@ export async function newSession(node: string | null, prompt: string, parent: st
1097
1116
  }
1098
1117
  writeRecord(rec)
1099
1118
  writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
1100
- // render the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
1119
+ // materialize the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
1101
1120
  // shims, manifest to the global store) so the launched agent gets the contract + hooks the SAME way a
1102
1121
  // self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
1103
1122
  // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
@@ -1121,9 +1140,9 @@ export async function newSession(node: string | null, prompt: string, parent: st
1121
1140
  return toSession(after, queued ? 'queued' : 'working', queued ? 'offline' : 'starting')
1122
1141
  }
1123
1142
 
1124
- // @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what renders
1125
- // the worktree's .claude/.codex shims (the settings.json hook wiring) in the first place, and the dispatch.sh
1126
- // re-render gate RIDES ON those hooks — so when this render fails, no hook ever fires, the gate never runs,
1143
+ // @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
1144
+ // the worktree's .claude/.codex shims (the settings.json hook wiring) in the first place, and every
1145
+ // lifecycle dispatch RIDES ON those hooks — so when this materialize fails, no hook ever fires,
1127
1146
  // and the worker comes up ungoverned (no contract block, no stop-gate) with nothing saying so. Fail loud
1128
1147
  // instead: log the cause + worktree, and stamp the failure on the record's `note` so the board/watch surface
1129
1148
  // it. The launch still proceeds — a visibly degraded worker the human can close + re-dispatch beats a refused
@@ -1134,7 +1153,7 @@ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string)
1134
1153
  doMaterialize(rec.worktreePath)
1135
1154
  } catch (e) {
1136
1155
  const msg = e instanceof Error ? e.message : String(e)
1137
- console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not rendered, worker launches UNGOVERNED: ${msg}`)
1156
+ console.error(`spex: materialize failed for worktree ${rec.worktreePath} — hooks/contract not materialized, worker launches UNGOVERNED: ${msg}`)
1138
1157
  writeRecord({ ...rec, note: `materialize failed at creation — worker ungoverned (no hooks/contract): ${msg}` })
1139
1158
  }
1140
1159
  }
@@ -1256,8 +1275,8 @@ export function markIdle(sessionId?: string): boolean {
1256
1275
  // done / propose merge. The dogfood ritual lands every change as a COMMIT on the node branch first, so two
1257
1276
  // states block a declaration: (1) any uncommitted working-tree change, or (2) 0 commits ahead of main
1258
1277
  // (nothing committed to merge). Since the global-store refactor, SpexCode writes NO per-session files into
1259
- // the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are gitignored
1260
- // (the materialize shims/skills) or tracked-and-committed (the contract block in CLAUDE.md/AGENTS.md), so
1278
+ // the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are exclude-
1279
+ // hidden materialized artifacts or filter-covered contract blocks ([[residence]]), so
1261
1280
  // neither shows as an uncommitted change — the worktree is pristine and EVERY dirty path is genuine spec/code
1262
1281
  // work, no runtime-file filtering needed.
1263
1282
  // Runs from cwd = the session worktree; ALL git goes through git() so the hook's exported GIT_DIR/GIT_INDEX_FILE
@@ -1446,12 +1465,17 @@ export async function exitSession(id: string): Promise<boolean> {
1446
1465
  // the session's whole global-store record dir — the work is gone, not just stopped. Same stop primitive as
1447
1466
  // exitSession (no duplicate kill path), then the git worktree/branch teardown that exit deliberately skips,
1448
1467
  // then the store sweep (exit KEEPS the record so the session stays on the board offline; close discards it).
1468
+ // The tree's materialize slot ([[runtime]] trees/<enc>) retires with the worktree — its key needs the live tree,
1469
+ // so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
1449
1470
  export async function closeSession(id: string): Promise<boolean> {
1450
1471
  const wt = await findWorktree(id)
1451
1472
  await stopAgentProcess(id)
1452
1473
  if (wt) {
1474
+ let slot: string | null = null
1475
+ try { slot = treeSlotDir(wt.path) } catch { /* tree already unresolvable — nothing to key the slot by */ }
1453
1476
  await gitA(['-C', mainRoot(), 'worktree', 'remove', '--force', wt.path])
1454
1477
  if (wt.branch) await gitA(['-C', mainRoot(), 'branch', '-D', wt.branch])
1478
+ if (slot) { try { rmSync(slot, { recursive: true, force: true }) } catch { /* best-effort GC */ } }
1455
1479
  }
1456
1480
  try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) } catch { /* best-effort sweep of the global record */ }
1457
1481
  void drainQueue() // a close frees a slot — start the next queued session if any
@@ -1638,8 +1662,12 @@ export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]
1638
1662
  // "block for a worker, then exit" that is GUARANTEED to return. The deadline is checked EVERY poll, before
1639
1663
  // EVERY sleep (and even when a poll throws), so a target stuck in ANY non-actionable state
1640
1664
  // (`working`/`parked`/`idle`/`queued`/`starting`) can never hang the caller — it exits at the deadline.
1641
- // `reached` = the target hit an actionable status; the rest are the loud exits.
1642
- 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' }
1643
1671
  export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
1644
1672
  const { source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
1645
1673
  const tag = as ? `[${as}] ` : ''
@@ -1693,7 +1721,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1693
1721
  // is rebooting its hot-reloaded child behind the stable port on a sibling merge. This is TRANSIENT:
1694
1722
  // record it, warn ONCE, and keep polling — the deadline (below) is the only hard wall, so a
1695
1723
  // backgrounded `spex wait` survives the ~1s restart instead of dying on the interrupted fetch.
1696
- 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' }
1697
1725
  if (isBackendDown(e)) {
1698
1726
  downMsg = (e as Error).message
1699
1727
  if (!warnedDown) { warnedDown = true; console.error(`${tag}[spex] watch: ${downMsg}; retrying every ${intervalMs / 1000}s…`) }
@@ -1703,7 +1731,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1703
1731
  // guarantees `spex wait` can never hang on a worker stuck outside WATCH_ACTIONABLE — nor spin forever on a
1704
1732
  // backend that never comes back. Hitting the deadline while still unreachable reports THAT (`backendDown`),
1705
1733
  // not a false "no actionable status" timeout, so the manager sees the honest cause.
1706
- 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 }
1707
1735
  await sleep(intervalMs)
1708
1736
  }
1709
1737
  }
@@ -102,7 +102,9 @@ function walk(dir: string, parent: string | null, acc: Raw[]) {
102
102
  // function over this same universe (every spec node), so a colliding leaf carries one canonical id
103
103
  // system-wide instead of a second, diverging bare-leaf scheme.
104
104
  export function mintIds(segs: string[][]): string[] {
105
- const suffix = (s: string[], k: number) => s.slice(s.length - k).join('_')
105
+ // NFC pins one canonical byte form for a non-ASCII dir name (macOS hands out NFD basenames), so a typed
106
+ // `[[中文节点]]` (NFC, what an IME emits) string-matches the minted id on every platform.
107
+ const suffix = (s: string[], k: number) => s.slice(s.length - k).join('_').normalize('NFC')
106
108
  return segs.map((s, i) => {
107
109
  let k = 1
108
110
  while (k < s.length && segs.some((o, j) => j !== i && o.length >= k && suffix(o, k) === suffix(s, k))) k++
@@ -357,7 +359,10 @@ function loadSurface(surface: 'command' | 'system' | 'hook' | 'skill' | 'agent')
357
359
  // @@@ skip pending - a `status: pending` plugin is DECLARED INTENT, not yet active. It renders on the
358
360
  // board (via loadSpecs) but must NOT gather: neither a command preset, nor folded into a system prompt,
359
361
  // nor a live hook. Only built/active plugins surface here, so pending stubs stay inert.
360
- if (str(fm.surface) === surface && str(fm.status) !== 'pending') {
362
+ // the surface field may name SEVERAL surfaces (comma-separated or a YAML list) — the node plugs
363
+ // into every one it lists, so the match is membership, not equality.
364
+ const surfaces = list(fm.surface).flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean)
365
+ if (surfaces.includes(surface) && str(fm.status) !== 'pending') {
361
366
  out.push({
362
367
  name,
363
368
  title: str(fm.title, name),
@@ -390,10 +395,10 @@ export function loadSystemConfig(): ConfigPreset[] { return loadSurface('system'
390
395
  // the hook handlers (compiled into the per-session hook manifest the dispatcher reads). Each carries its
391
396
  // `events`/`order`/`block` binding + co-located script `files`.
392
397
  export function loadHookConfig(): ConfigPreset[] { return loadSurface('hook') }
393
- // the skill bundles (rendered into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
398
+ // the skill bundles (materialized into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
394
399
  // load-trigger and its `body` is the on-demand instructions; loadSurface passes the folder basename as `name`.
395
400
  export function loadSkillConfig(): ConfigPreset[] { return loadSurface('skill') }
396
- // the sub-agent definitions (rendered into each harness's auto-discovered agent dir, e.g. claude's
401
+ // the sub-agent definitions (materialized into each harness's auto-discovered agent dir, e.g. claude's
397
402
  // .claude/agents/<name>.md). Like a skill, the node's `desc` is the on-demand load-trigger and its `body` is the
398
403
  // agent's system prompt; additionally its `tools` field is the harness tool allowlist for the spawned agent.
399
404
  export function loadAgentConfig(): ConfigPreset[] { return loadSurface('agent') }
@@ -8,8 +8,8 @@ import { loadSkillConfig, loadAgentConfig } from './specs.js'
8
8
  import { dematerialize } from './materialize.js'
9
9
 
10
10
  // @@@ spex-uninstall - materialize(∅) plus the store: the in-tree/global-config backout IS dematerialize (the
11
- // same identity-stamped erase phase every render runs first — the forgetting law's empty policy), and this
12
- // command adds only what a render never owns per-run: the global per-project store, the plugin-bundle sweep,
11
+ // same identity-stamped erase phase every materialize runs first — the forgetting law's empty policy), and this
12
+ // command adds only what a materialize never owns per-run: the global per-project store, the plugin-bundle sweep,
13
13
  // and the optional git hooks. EVERY removal is gated on a SpexCode IDENTITY STAMP (the managed-block
14
14
  // sentinels, the shim's own dispatch.sh command line, the trust sentinels, the generated mark / name-scoped
15
15
  // on-demand paths, the plugin name stamp), so it can only ever delete what SpexCode itself generated. The one
@@ -100,11 +100,12 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
100
100
 
101
101
  // 1+2. materialize(∅): every harness's artifacts (contract block, shim, trust, skills/agents), the managed
102
102
  // .gitignore + info/exclude blocks, any legacy skip-worktree bit, and the content filter — the SAME
103
- // erase phase every render runs, asserted against the empty policy. One inverse, never a parallel one.
103
+ // erase phase every materialize runs, asserted against the empty policy. One inverse, never a parallel one.
104
104
  dematerialize(proj, arts)
105
105
 
106
- // 3. the global per-project store — manifest + content-hash marker + gate lock + session records. This is the
107
- // runtime tier, not the user's spec asset, so the whole dir is ours.
106
+ // 3. the global per-project store — the per-tree materialize slots (trees/<enc>: manifest + content-hash +
107
+ // plugin ledger), any legacy pre-slot manifest, and the session records. This is the runtime tier,
108
+ // not the user's spec asset, so the whole dir is ours.
108
109
  let store: string | null = null
109
110
  try {
110
111
  store = runtimeRoot(proj)
@@ -2,14 +2,16 @@ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync } fro
2
2
  import { dirname, join } from 'node:path'
3
3
  import { git } from './git.js'
4
4
 
5
- // @@@ worktree-sources ([[render-policy]]) - a fresh session worktree is fed by THREE transports, one per
5
+ // @@@ worktree-sources ([[residence]]) - a fresh session worktree is fed by THREE transports, one per
6
6
  // source kind, and the kind decides the transport — never a mode branch:
7
7
  // - TRACKED project state (`.spec`, `spexcode.json`) arrives by GIT CHECKOUT: the sources are always
8
8
  // tracked (git is the database), so `git worktree add` alone delivers them. No symlink — a link is a
9
9
  // WRITE-SEMANTICS declaration (write-through to the main tree), and spec writes go back through the
10
10
  // branch/merge ritual, not through a side channel.
11
- // - RENDERS (contract blocks, shims, skills) are DERIVED — transported by re-render, not by link or copy:
12
- // sessions.ts materializes into the worktree at creation, and the dispatch gate re-renders on change.
11
+ // - MATERIALIZED ARTIFACTS (contract blocks, shims, skills) are DERIVED — transported by re-materialize,
12
+ // not by link or copy:
13
+ // sessions.ts materializes into the worktree at creation, and the git-native anchors (pre-commit /
14
+ // post-checkout / post-merge — [[commit-surgery]]) re-materialize on change.
13
15
  // - HOST state (`spexcode.local.json`, machine-local and never tracked) is COPIED — a snapshot: the worker
14
16
  // reads the same launchers/policy the host had at dispatch, but its writes land on its own copy and die
15
17
  // with the worktree, never on the host's real config (a worker once wrote "its" test config through the
@@ -31,7 +33,7 @@ export function seedWorktreeHostState(main: string, wt: string): void {
31
33
  // what we seed, we hide: a seeded entry git still sees is force-add bait (a real PR once carried seeded
32
34
  // files into a product repo). `.git/info/exclude` lives in the COMMON git dir, so one write hides the entry
33
35
  // in every linked worktree AND the main checkout. Only entries seeded by THIS call and reported un-ignored
34
- // by `git check-ignore` are written: idempotent across dispatches, and a repo whose render already ignores
36
+ // by `git check-ignore` are written: idempotent across dispatches, and a repo whose materialize already ignores
35
37
  // the overlay (materialize's block under any policy) writes nothing — the self-heal for a half-configured repo.
36
38
  function hideSeededFromGit(wt: string, seeded: string[]): void {
37
39
  for (const f of seeded) {
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # @@@ footprint refresh (post-checkout) ([[commit-surgery]]) - a branch checkout is one of the three git
3
+ # transitions that can move the materialize's inputs: .spec/.config content changes across branches, and a
4
+ # contract file's TRACKEDNESS can flip (switching to a branch that tracks CLAUDE.md checks out the pristine
5
+ # index prose — the re-materialize here writes the block back into the working file, binds the clean/smudge
6
+ # filter, and withdraws the exclude entry; the kind-transition heals itself). Git-native anchors only —
7
+ # never a harness event. Quiet, best-effort: a failed refresh self-heals at the next anchor (pre-commit's
8
+ # unconditional materialize is the correctness backstop).
9
+ #
10
+ # args: <prev-HEAD> <new-HEAD> <flag>; flag=1 is a branch checkout, flag=0 a file checkout (git checkout --
11
+ # <path> restores files and moves nothing the materialize depends on — skip those).
12
+ [ "${3:-0}" = "1" ] || exit 0
13
+ main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
14
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
15
+ if command -v spex >/dev/null 2>&1; then
16
+ spex internal refresh-footprint >/dev/null 2>&1 || true
17
+ elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
18
+ "$repo_root/node_modules/.bin/spex" internal refresh-footprint >/dev/null 2>&1 || true
19
+ elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
20
+ "$main_root/spec-cli/bin/spex.mjs" internal refresh-footprint >/dev/null 2>&1 || true
21
+ fi
22
+ exit 0
@@ -9,19 +9,25 @@
9
9
  # the feature is OFF in spexcode.json), so this hook is a THIN resolver + caller: resolve `spex` the same
10
10
  # three ways the pre-commit hook does, pass the merged node, echo whatever it prints. No CLI resolvable → no
11
11
  # nudge (safe degradation, same as the lint shim's advisory mode).
12
+ main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
13
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
14
+ spex_run() {
15
+ if command -v spex >/dev/null 2>&1; then spex "$@"
16
+ elif [ -x "$repo_root/node_modules/.bin/spex" ]; then "$repo_root/node_modules/.bin/spex" "$@"
17
+ elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then "$main_root/spec-cli/bin/spex.mjs" "$@"
18
+ else return 127; fi
19
+ }
20
+
21
+ # @@@ footprint refresh ([[commit-surgery]]) - a merge is one of the three git transitions that can move the
22
+ # materialize's inputs (.spec/.config content, contract-file trackedness), so re-materialize here — git-native
23
+ # anchors only, never a harness event. Quiet, best-effort: a failed refresh self-heals at the next anchor.
24
+ spex_run internal refresh-footprint >/dev/null 2>&1 || true
25
+
12
26
  subj=$(git log -1 --format=%s 2>/dev/null)
13
27
  case "$subj" in
14
28
  "merge node/"*) ;;
15
29
  *) exit 0 ;;
16
30
  esac
17
31
  node=$(printf '%s' "$subj" | sed -n 's#^merge \(node/[^:]*\):.*#\1#p')
18
- main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
19
- repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
20
- if command -v spex >/dev/null 2>&1; then
21
- spex issues nudge "$node"
22
- elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
23
- "$repo_root/node_modules/.bin/spex" issues nudge "$node"
24
- elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
25
- "$main_root/spec-cli/bin/spex.mjs" issues nudge "$node"
26
- fi
32
+ spex_run issues nudge "$node"
27
33
  exit 0
@@ -64,6 +64,16 @@ if [ "$branch" = "$trunk" ] && [ ! -f "$git_dir/MERGE_HEAD" ] && [ -z "${SPEXCOD
64
64
  exit 1
65
65
  fi
66
66
 
67
+ # @@@ footprint surgery ([[commit-surgery]]) - the history anchor: an UNCONDITIONAL materialize (masks fresh
68
+ # at the one moment history is written) + staged-index repair — strip the spexcode sentinel block from any
69
+ # staged blob (source = the staged blob, so `git add -p` partial staging survives), unstage HEAD-untracked
70
+ # generated artifacts. REPAIRS AND PROCEEDS, never rejects; one note per repair on stderr. Runs INSIDE the
71
+ # hook env on purpose: the surgery honors GIT_INDEX_FILE so a pathspec/`-a` commit's TEMPORARY index is the
72
+ # one repaired. Advisory when spex is unresolvable or errors (CI lint enforces); never blocks the commit.
73
+ if [ -n "$spex_kind" ]; then
74
+ spex_cli internal commit-surgery || echo "• SpexCode: footprint surgery skipped (error above) — a generated artifact may be staged; CI still enforces." >&2
75
+ fi
76
+
67
77
  # @@@ spec-lint - the hook is just a thin shim over `spex lint`. It blocks on errors only (broken
68
78
  # spec↔code links); coverage/drift are warnings. Bypass with SPEXCODE_SKIP_LINT=1. It shims through the
69
79
  # shared spex_cli() resolved above (repoRoot() discovers the cwd's toplevel via git.ts, which strips the
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // digest.mjs — locate a dead session's transcript on disk and print a compact markdown digest.
3
+ // Usage: node digest.mjs <session-id | path/to/transcript.jsonl>
4
+ // Read-only: never resumes, prompts, or mutates the session. Exit 1 (loud) when no transcript is found.
5
+ //
6
+ // Harness coverage: claude (projects/<enc-cwd>/<id>.jsonl) and codex (sessions/YYYY/MM/DD/rollout-*<id>.jsonl).
7
+ // The digest keeps the high-signal stream — user prompts in full, assistant text, tool calls as one-liners,
8
+ // error results — and drops the bulk (tool outputs, attachments, sidechains, reasoning).
9
+
10
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { homedir } from 'node:os'
13
+
14
+ const arg = process.argv[2]
15
+ if (!arg) { console.error('usage: digest.mjs <session-id | transcript.jsonl>'); process.exit(1) }
16
+
17
+ const TRUNC = (s, n) => { s = String(s ?? '').trim(); return s.length > n ? s.slice(0, n) + ` …[+${s.length - n} chars]` : s }
18
+
19
+ // ---- locate -------------------------------------------------------------
20
+ const claudeRoots = () => {
21
+ const roots = []
22
+ if (process.env.CLAUDE_CONFIG_DIR) roots.push(process.env.CLAUDE_CONFIG_DIR)
23
+ for (const e of readdirSync(homedir(), { withFileTypes: true }))
24
+ if (e.isDirectory() && e.name.startsWith('.claude')) roots.push(join(homedir(), e.name))
25
+ return roots
26
+ }
27
+ const findClaude = (id) => {
28
+ for (const root of claudeRoots()) {
29
+ const proj = join(root, 'projects')
30
+ if (!existsSync(proj)) continue
31
+ for (const d of readdirSync(proj)) {
32
+ const f = join(proj, d, `${id}.jsonl`)
33
+ if (existsSync(f)) return f
34
+ }
35
+ }
36
+ return null
37
+ }
38
+ const findCodex = (id) => {
39
+ const root = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
40
+ if (!existsSync(root)) return null
41
+ const stack = [root]
42
+ while (stack.length) {
43
+ const dir = stack.pop()
44
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
45
+ if (e.isDirectory()) stack.push(join(dir, e.name))
46
+ else if (e.name.startsWith('rollout-') && e.name.includes(id) && e.name.endsWith('.jsonl')) return join(dir, e.name)
47
+ }
48
+ }
49
+ return null
50
+ }
51
+ const file = arg.endsWith('.jsonl') ? arg : (findClaude(arg) || findCodex(arg))
52
+ if (!file || !existsSync(file)) { console.error(`no transcript found for "${arg}" (searched claude projects/ and codex sessions/)`); process.exit(1) }
53
+
54
+ // ---- parse --------------------------------------------------------------
55
+ const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean)
56
+ const out = []
57
+ const meta = { cwd: null, branch: null, first: null, last: null }
58
+ const filesTouched = new Set()
59
+ let errors = 0
60
+ const seen = new Set()
61
+
62
+ // harness-injected preamble arrives typed as "user" (system-reminders, AGENTS.md folds, codex permission/skill
63
+ // blocks) — it is noise to a digest, and real human prompts essentially never open with these markers.
64
+ const isInjected = (t) => /^(<|# AGENTS\.md instructions|Caveat: The messages below)/.test(t.trimStart())
65
+ const userBlock = (t) => { t = String(t ?? '').trim(); return t && !isInjected(t) ? `\n## user\n${t}` : null }
66
+
67
+ const toolLine = (name, input = {}) => {
68
+ if ((name === 'Edit' || name === 'Write' || name === 'NotebookEdit') && input.file_path) filesTouched.add(input.file_path)
69
+ const hint = input.description || input.file_path || input.command || input.prompt || input.query || input.pattern || ''
70
+ return `→ ${name}${hint ? ` · ${TRUNC(hint, 160)}` : ''}`
71
+ }
72
+
73
+ for (const raw of lines) {
74
+ let l; try { l = JSON.parse(raw) } catch { continue }
75
+ const ts = l.timestamp
76
+ if (ts) { meta.first ||= ts; meta.last = ts }
77
+ if (l.cwd) meta.cwd ||= l.cwd
78
+ if (l.gitBranch && l.gitBranch !== 'HEAD') meta.branch ||= l.gitBranch
79
+
80
+ // claude shape: {type: user|assistant, message:{content}, isSidechain}
81
+ if (l.type === 'user' || l.type === 'assistant') {
82
+ if (l.isSidechain) continue // subagent noise
83
+ const c = l.message?.content
84
+ if (typeof c === 'string') { const u = userBlock(c); if (u) out.push(u); continue }
85
+ for (const item of c || []) {
86
+ if (item.type === 'text' && item.text?.trim()) {
87
+ if (l.type === 'user') { const u = userBlock(item.text); if (u) out.push(u) }
88
+ else out.push(TRUNC(item.text, 2000))
89
+ }
90
+ else if (item.type === 'tool_use') out.push(toolLine(item.name, item.input))
91
+ else if (item.type === 'tool_result' && item.is_error) {
92
+ errors++
93
+ const t = Array.isArray(item.content) ? item.content.map((x) => x.text || '').join(' ') : item.content
94
+ out.push(`⚠ tool error: ${TRUNC(t, 400)}`)
95
+ }
96
+ }
97
+ continue
98
+ }
99
+
100
+ // codex shape: {type: session_meta|response_item|event_msg, payload:{...}}. The same message is logged
101
+ // BOTH as a response_item and an event_msg (version-dependent which exists) — dedupe on the text.
102
+ const p = l.payload
103
+ if (!p) continue
104
+ if (l.type === 'session_meta') { meta.cwd ||= p.cwd; continue }
105
+ const pushMsg = (role, text) => {
106
+ text = String(text ?? '').trim()
107
+ if (!text || seen.has(text)) return
108
+ seen.add(text)
109
+ if (role === 'user') { const u = userBlock(text); if (u) out.push(u) }
110
+ else if (role === 'assistant') out.push(TRUNC(text, 2000)) // developer/system roles are harness plumbing
111
+ }
112
+ if (l.type === 'event_msg' && (p.type === 'user_message' || p.type === 'agent_message'))
113
+ pushMsg(p.type === 'user_message' ? 'user' : 'assistant', p.message)
114
+ else if (l.type === 'response_item') {
115
+ if (p.type === 'message') {
116
+ pushMsg(p.role, (p.content || []).map((x) => x.text || '').join('\n'))
117
+ } else if (p.type === 'function_call') {
118
+ let input = {}; try { input = JSON.parse(p.arguments || '{}') } catch {}
119
+ out.push(toolLine(p.name, input))
120
+ } else if (p.type === 'function_call_output' && /error/i.test(String(p.output).slice(0, 200))) {
121
+ errors++
122
+ out.push(`⚠ tool error: ${TRUNC(p.output, 400)}`)
123
+ }
124
+ }
125
+ }
126
+
127
+ // ---- emit ---------------------------------------------------------------
128
+ const kb = Math.round(statSync(file).size / 1024)
129
+ console.log(`# transcript digest — ${arg}`)
130
+ console.log(`- file: ${file} (${kb} KB, ${lines.length} lines)`)
131
+ if (meta.first) console.log(`- span: ${meta.first} → ${meta.last}`)
132
+ if (meta.cwd) console.log(`- cwd: ${meta.cwd}`)
133
+ if (meta.branch) console.log(`- branch: ${meta.branch}`)
134
+ console.log(out.join('\n'))
135
+ console.log(`\n---\n- transcript: ${file}\n- tool errors seen: ${errors}`)
136
+ if (filesTouched.size) console.log(`- files edited:\n${[...filesTouched].map((f) => ` - ${f}`).join('\n')}`)
@@ -0,0 +1,74 @@
1
+ ---
2
+ title: distill
3
+ surface: skill, command
4
+ status: active
5
+ hue: 210
6
+ desc: Use when the human wants to inherit a past or dead session's knowledge and work — "distill session X / 继承那个 session 的经验 / 接手它的工作 / 把之前 session 的东西捞回来 / harvest, salvage a finished session". Given a session id, read its transcript from disk (NEVER resume or re-prompt it — its cache is cold and a re-prime is expensive), distill goal · decisions · traps · next steps into the current session, and if its worktree/branch never merged, carry the work over and retire the resources.
7
+ ---
8
+
9
+ # distill
10
+
11
+ Inherit a finished (or dead) session's **mind and desk** without waking it — mind = its transcript on disk,
12
+ desk = its worktree/branch. The one iron rule: **never resume, reopen, send to, or otherwise re-prompt the
13
+ old session** (cold cache: any turn pays a full re-prime). Everything below is read-only files and plain git.
14
+
15
+ ## 1 · resolve the session
16
+
17
+ Input: a session id — SpexCode's, a bare harness id (claude / codex thread), or a transcript `.jsonl` path.
18
+
19
+ - **SpexCode session** (first choice — the join is first-class): its record is
20
+ `~/.spexcode/projects/*/sessions/<id>/session.json` — glob for the id, prefix ok. Take `worktree_path`,
21
+ `branch`, `harness`, `harness_session_id`, `status`, `title`; the originating goal is
22
+ `spex session prompt <id>`. For a claude-harness session the transcript id IS the SpexCode session id;
23
+ for codex it is `harness_session_id`.
24
+ - **Any other session**: treat the arg as the harness's own id. The transcript carries `cwd` (and, unless
25
+ the worktree was detached, a branch) — the digest header surfaces them; that is your join to its desk.
26
+
27
+ ## 2 · digest the transcript — mechanical first, model second
28
+
29
+ `node .spec/<root>/.config/distill/digest.mjs <id-or-path>` locates the transcript (claude:
30
+ `$CLAUDE_CONFIG_DIR` and every `~/.claude*` config dir → `projects/*/<id>.jsonl`; codex: `$CODEX_HOME` or
31
+ `~/.codex` → `sessions/**/rollout-*<id>.jsonl`) and prints a compact digest: the human's prompts in full,
32
+ the agent's own text, tool calls as one-liners, error results, and a footer with the files it edited and
33
+ the raw transcript path. It exits loud when nothing is found — do not fall back to resuming the session.
34
+
35
+ Read the digest yourself when small; big (>~100 KB) → a subagent returns only the distillation below, so
36
+ the inheritance never floods your own context. Its ⚠ error lines and footer are step 3's trap material.
37
+
38
+ ## 3 · distill — forward-looking, not narrative
39
+
40
+ Completed work is git's job to remember; do not re-narrate it — and never paste raw transcript. State in
41
+ your reply, and work from, what the transcript knows that git does not:
42
+
43
+ - **Goal & landing** — what it set out to do, and where it actually stopped (merged? proposal pending?
44
+ abandoned mid-flight?).
45
+ - **Decisions & why** — the direction that was settled, including options weighed and rejected.
46
+ - **Traps** — failures, dead ends, gotchas, and every correction the human made. These are the
47
+ highest-value lines in the whole transcript.
48
+ - **Unfinished / next actions** — what it would have done next.
49
+ - **Pointers** — files edited, spec nodes touched, and the raw transcript path itself, so later questions
50
+ drill into the source instead of inheriting everything up front.
51
+
52
+ ## 4 · salvage the desk
53
+
54
+ The SpexCode record names the worktree/branch; otherwise the digest's `cwd` may be a linked worktree
55
+ (`git -C <cwd> rev-parse --git-common-dir`). Salvage inside that repo — it need not be the one you sit in.
56
+ Cross-check the digest's files-edited footer against that worktree: a manager-style session's edits often
57
+ live OUTSIDE it (main-checkout config, other repos) — those need a by-hand look, not the recipe below.
58
+
59
+ - **Already merged** (`git merge-base --is-ancestor <branch> <trunk>`) → nothing to salvage; note it and
60
+ go to cleanup. A tip that EQUALS the merge-base carried no commits — say "never committed", not "merged".
61
+ - **Unmerged commits** → carry them onto your current branch: `git cherry-pick <base>..<branch>` (keeps
62
+ authorship and `Session:` trailers); fall back to applying `git diff <base> <branch>` when the history
63
+ is too messy to replay.
64
+ - **Uncommitted changes** in the old worktree → `git -C <wt> status --porcelain`; apply its diff to your
65
+ tree and copy untracked files over. Commit the salvage in your own tree, naming the origin session in
66
+ the message.
67
+
68
+ ## 5 · clean up — only after the salvage LANDED
69
+
70
+ Cleanup discards state — verify the salvaged commits are in your tree (or the branch genuinely merged) first.
71
+
72
+ - SpexCode session: `spex session close <id>` retires the session and its worktree in one verb.
73
+ - Bare worktree: `git worktree remove <wt>`, + `git branch -D <branch>` once confirmed carried or merged.
74
+ - In doubt, keep the resources and say so — a kept worktree costs disk; a wrong cleanup costs the work.
@@ -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: