spexcode 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +158 -103
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +24 -1
  4. package/spec-cli/src/attach.ts +50 -0
  5. package/spec-cli/src/cli.ts +217 -64
  6. package/spec-cli/src/client.ts +47 -9
  7. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  8. package/spec-cli/src/guide.ts +79 -21
  9. package/spec-cli/src/harness.ts +53 -29
  10. package/spec-cli/src/help.ts +137 -49
  11. package/spec-cli/src/index.ts +31 -11
  12. package/spec-cli/src/issues.ts +48 -21
  13. package/spec-cli/src/layout.ts +3 -5
  14. package/spec-cli/src/lint.ts +34 -5
  15. package/spec-cli/src/localIssues.ts +44 -60
  16. package/spec-cli/src/materialize.ts +4 -2
  17. package/spec-cli/src/mentions.ts +22 -1
  18. package/spec-cli/src/pty-bridge.ts +39 -4
  19. package/spec-cli/src/ranker.ts +31 -12
  20. package/spec-cli/src/search.bench.mjs +30 -7
  21. package/spec-cli/src/search.ts +39 -0
  22. package/spec-cli/src/sessions.ts +160 -69
  23. package/spec-cli/src/specs.ts +16 -4
  24. package/spec-cli/src/supervise.ts +30 -6
  25. package/spec-cli/src/tree.ts +118 -0
  26. package/spec-cli/templates/hooks/post-merge +2 -2
  27. package/spec-cli/templates/hooks/pre-commit +34 -15
  28. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  29. package/spec-cli/templates/spexcode.json +7 -0
  30. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  31. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +27 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-B5leylLf.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-RHNECU6x.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-YLD6IOmC.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-ZnOwskMZ.js +1 -0
  40. package/spec-dashboard/dist/assets/index-BdRQfrkR.js +41 -0
  41. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +1 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-yatsu/src/cli.ts +128 -26
  44. package/spec-yatsu/src/evaltab.ts +7 -6
  45. package/spec-yatsu/src/filing.ts +6 -3
  46. package/spec-yatsu/src/proof.ts +10 -0
  47. package/spec-yatsu/src/scenariofresh.ts +100 -30
  48. package/spec-yatsu/src/sidecar.ts +25 -3
  49. package/spec-yatsu/src/timeline.ts +53 -23
  50. package/README.zh-CN.md +0 -135
  51. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
  52. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
@@ -1,5 +1,10 @@
1
- // throwaway benchmark harness for spec-search — drives the REAL `spex search --json` over the 15 holdout
2
- // cases and reports recall@1, recall@3, MRR. Not shipped/committed; the cases live in the node's yatsu.md.
1
+ // throwaway benchmark harness for spec-search — drives the REAL `spex search --json` over the holdout
2
+ // cases and reports recall@1, recall@3, MRR. The cases live in the node's yatsu.md.
3
+ //
4
+ // Labels are node LEAF names, matched with the same de-collision rule the loader applies (specs.ts reId):
5
+ // a returned id matches a label if it IS the label or ends with `_<label>` — so a bare leaf keeps matching
6
+ // after collision-qualification renames it (e.g. `spec-scout` → `injected-context_spec-scout`). A label may
7
+ // also be written pre-qualified to pin one collision branch.
3
8
  import { execFileSync } from 'node:child_process'
4
9
  import { fileURLToPath } from 'node:url'
5
10
  import { dirname, join } from 'node:path'
@@ -8,29 +13,33 @@ const BIN = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'spex.mjs
8
13
 
9
14
  const CASES = [
10
15
  ['exit-cleanup', "does /exit remove the session's worktree and tmux, or just orphan them?", ['session-console']],
11
- ['owner-at-edit', 'how does an agent learn which spec governs a file it just edited?', ['injected-context/spec-of-file']],
16
+ ['owner-at-edit', 'how does an agent learn which spec governs a file it just edited?', ['spec-of-file']],
12
17
  ['main-block', 'what stops an agent from committing or merging straight into main?', ['main-guard']],
13
18
  ['main-escape', 'the escape hatch that lets seeding run on the main branch', ['main-guard']],
14
19
  ['inter-agent-msg', 'how do two running agent sessions send messages to each other?', ['agent-reply-channel', 'comms']],
15
20
  ['search-hidden-node', 'keyboard shortcut to find a node hidden inside a collapsed subtree', ['keyboard-nav']],
16
- ['session-order', 'how is the order of sessions in the session list decided?', ['session-reorder']],
21
+ ['session-order', 'how is the order of sessions in the session list decided?', ['session-console']],
17
22
  ['node-status', 'what makes a node show as pending vs active vs merged vs drift?', ['spec-node-states']],
18
23
  ['dashboard-backend', 'how does the dashboard reach the backend API and on which port?', ['api-endpoint']],
19
24
  ['loss-measured', "how is a node's loss measured and its scenarios scored?", ['yatsu-core']],
20
25
  ['launch-injection', "what context gets injected into a freshly launched agent's prompt?", ['injected-context']],
21
- ['read-before-code', 'the one-shot nudge that makes an agent read its spec before touching code', ['injected-context/spec-first']],
26
+ ['read-before-code', 'the one-shot nudge that makes an agent read its spec before touching code', ['spec-first']],
22
27
  ['hot-reload', 'zero-downtime backend reload without dropping connections', ['supervisor']],
23
28
  ['many-owners', 'can several specs own the same code file, and what happens if too many do?', ['governed-related']],
24
29
  ['active-spec-search', 'an injected sub-agent that searches specs for the agent, the spec analog of Explore', ['spec-scout']],
30
+ ['declare-done', 'how does a worker declare it is done', ['state']],
25
31
  ]
26
32
 
33
+ // de-collision-aware label match: exact id, or the id's trailing `_`-suffix is the label.
34
+ const matches = (id, label) => id === label || id.endsWith('_' + label)
35
+
27
36
  let r1 = 0, r3 = 0, mrr = 0
28
37
  const rows = []
29
38
  for (const [name, query, expect] of CASES) {
30
39
  const out = execFileSync('node', [BIN, 'search', query, '--json', '--limit', '10'], { encoding: 'utf8' })
31
40
  const results = JSON.parse(out)
32
41
  const ids = results.map((x) => x.id)
33
- const rank = ids.findIndex((id) => expect.includes(id)) + 1 // 1-based; 0 = not found
42
+ const rank = ids.findIndex((id) => expect.some((label) => matches(id, label))) + 1 // 1-based; 0 = not found
34
43
  const hit1 = rank === 1
35
44
  const hit3 = rank >= 1 && rank <= 3
36
45
  if (hit1) r1++
@@ -43,5 +52,19 @@ for (const row of rows) {
43
52
  const mark = row.rank === 1 ? '✓1' : (typeof row.rank === 'number' && row.rank <= 3 ? '·3' : (row.rank === '—' ? '✗ ' : `·${row.rank}`))
44
53
  console.log(`${mark} ${row.name.padEnd(20)} want=${row.expect.padEnd(22)} rank=${String(row.rank).padStart(2)} top3: ${row.top}`)
45
54
  }
55
+
56
+ // zero-result fail-loud regression: a CJK query over the English corpus returns nothing, and the
57
+ // zero-result message must carry the corpus-is-English translate-and-retry fact (fail-loud, unconditional)
58
+ // plus the browse-all next step (no nearest titles — a CJK query has nothing to be lexically near).
59
+ const cjk = execFileSync('node', [BIN, 'search', '重命名一个会话'], { encoding: 'utf8' })
60
+ const cjkPass = cjk.includes('corpus is English') && cjk.includes('spex tree')
61
+ console.log(`${cjkPass ? '✓ ' : '✗ '} cjk-zero-result want=corpus-is-English + spex-tree ${cjkPass ? 'both present' : 'MISSING: ' + cjk.trim()}`)
62
+
63
+ // zero-result typo routing: an English typo that matches nothing must surface the nearest node titles
64
+ // (per-word edit distance) plus the browse-all next step, so a typo routes forward instead of dead-ending.
65
+ const typo = execFileSync('node', [BIN, 'search', 'kyeboard'], { encoding: 'utf8' })
66
+ const typoPass = typo.includes('nearest titles') && typo.includes('keyboard-nav') && typo.includes('spex tree')
67
+ console.log(`${typoPass ? '✓ ' : '✗ '} typo-zero-result want=nearest-titles(keyboard-nav) + spex-tree ${typoPass ? 'both present' : 'MISSING: ' + typo.trim()}`)
68
+
46
69
  console.log('—'.repeat(72))
47
- console.log(`recall@1 = ${r1}/${n} = ${(r1 / n).toFixed(3)} recall@3 = ${r3}/${n} = ${(r3 / n).toFixed(3)} MRR = ${(mrr / n).toFixed(3)}`)
70
+ console.log(`recall@1 = ${r1}/${n} = ${(r1 / n).toFixed(3)} recall@3 = ${r3}/${n} = ${(r3 / n).toFixed(3)} MRR = ${(mrr / n).toFixed(3)} cjk-hint = ${cjkPass ? 'PASS' : 'FAIL'} typo-route = ${typoPass ? 'PASS' : 'FAIL'}`)
@@ -4,6 +4,45 @@ import { rankDocs, type RankInput } from './ranker.js'
4
4
  export type SearchResult = { id: string; title: string; path: string; score: number; snippet: string }
5
5
  export type SearchStats = { nodes: number; tokens: number; ms: number }
6
6
 
7
+ // zero-result fallback: nearest node titles by per-word edit distance over title+id — the lightweight
8
+ // string distance that routes a typo'd query to a next step. Each query word takes its best normalised
9
+ // Levenshtein similarity against any title/id word (≥0.5, so unrelated words don't accumulate), then the
10
+ // words sum. Same loadSpecsLite read as the ranker; deliberately NOT part of rankDocs scoring (this
11
+ // tolerates typos, the ranker must not).
12
+ export function nearestTitles(query: string, n = 3): { id: string; title: string }[] {
13
+ const words = (s: string) => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1)
14
+ const lev = (a: string, b: string) => {
15
+ const dp = Array.from({ length: b.length + 1 }, (_, i) => i)
16
+ for (let i = 1; i <= a.length; i++) {
17
+ let prev = dp[0]
18
+ dp[0] = i
19
+ for (let j = 1; j <= b.length; j++) {
20
+ const cur = dp[j]
21
+ dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
22
+ prev = cur
23
+ }
24
+ }
25
+ return dp[b.length]
26
+ }
27
+ const qws = words(query)
28
+ if (!qws.length) return [] // nothing to be "near" (e.g. a pure-CJK query) — the caller still points at spex tree
29
+ return loadSpecsLite()
30
+ .map((s) => {
31
+ const tws = [...new Set(words(`${s.title} ${s.id}`))]
32
+ let score = 0
33
+ for (const q of qws) {
34
+ let best = 0
35
+ for (const t of tws) best = Math.max(best, 1 - lev(q, t) / Math.max(q.length, t.length))
36
+ if (best >= 0.5) score += best
37
+ }
38
+ return { id: s.id, title: s.title, score }
39
+ })
40
+ .filter((s) => s.score > 0)
41
+ .sort((a, b) => b.score - a.score)
42
+ .slice(0, n)
43
+ .map(({ id, title }) => ({ id, title }))
44
+ }
45
+
7
46
  export async function searchSpecs(query: string, opts: { limit?: number; onStats?: (s: SearchStats) => void } = {}): Promise<SearchResult[]> {
8
47
  const t0 = performance.now()
9
48
  // pre-sort by (shorter id, then id): rankDocs sorts stably, so for equal-scored nodes this IS the tiebreak.
@@ -6,9 +6,10 @@ import { join, dirname, relative, isAbsolute } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
8
8
  import { loadSpecs } from './specs.js'
9
- import { defaultHarness, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
9
+ import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
10
10
  import { materialize } from './materialize.js'
11
11
  import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
12
+ import { stripRefSigil } from './mentions.js'
12
13
 
13
14
  // @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
14
15
  // SOURCE OF TRUTH is an untracked record (`session.json`) in a per-user GLOBAL store keyed by the harness
@@ -42,13 +43,13 @@ import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, ses
42
43
  // deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
43
44
  //
44
45
  // Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
45
- // SPEXCODE_TMUX / SPEXCODE_CLAUDE_CMD override both for tests.
46
+ // SPEXCODE_TMUX overrides the tmux socket for tests; the launch COMMAND comes from the session's pinned
47
+ // launcher ([[launcher-select]]), not an env var.
46
48
 
47
49
  const pexec = promisify(execFile)
48
- const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
49
- // the harness the dashboard/CLI launcher drives. ALL harness-specific launch facts (the agent command, the
50
- // session-id flag, the hook shim, the session env var) come from this adapter — the launcher never names
51
- // Claude. Resolved once here ([[harness-adapter]]); a future codex launcher flips defaultHarness, nothing else.
50
+ export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
51
+ // the legacy/default harness for helpers and old records. New sessions derive their harness from the selected
52
+ // launcher; all harness-specific launch facts still come from the adapter.
52
53
  const HARNESS = defaultHarness
53
54
  const COLS = 120, ROWS = 32
54
55
  // @@@ concurrency cap - the most working agents we let run AT ONCE. Heavy multi-agent load (many claude
@@ -114,7 +115,7 @@ export type Session = {
114
115
  raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
115
116
  parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex new` ran inside another session, else null; the frontend folds a child under it at read time
116
117
  harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
117
- launcher: string | null // the named launcher profile this session launched under ([[launcher-select]]); null an unnamed/zero-config launch. Surfaced so a row can badge WHICH launcher (glyph + name) actually brought the worker up
118
+ launcher: string | null // the launcher profile this session launched under ([[launcher-select]]); null only for old records predating launchers
118
119
  lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
119
120
  prompt: string | null; promptPreview: string | null; created: number; activity: string | null
120
121
  sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
@@ -224,7 +225,7 @@ export type SessRec = {
224
225
  parent: string | null // the spawning session's id ([[session-nesting]]); null for a top-level launch
225
226
  status: Lifecycle; proposal: Proposal | null; merges: number; note: string | null
226
227
  sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
227
- launcher: string | null // the named launcher profile this session launches under ([[launcher-select]]); null the unnamed global default (an old record, or a zero-config launch)
228
+ launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
228
229
  launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
229
230
  }
230
231
  const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
@@ -250,7 +251,7 @@ function fromRaw(raw: RawRecord): SessRec {
250
251
  status, proposal, merges: Number(raw.merges) || 0, note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
251
252
  harness: raw.harness || 'claude', // records written before the harness field default to claude
252
253
  harnessSessionId: raw.harness_session_id || null,
253
- launcher: raw.launcher || null, // records written before launchers → null → the unnamed global resolution
254
+ launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
254
255
  launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
255
256
  }
256
257
  }
@@ -371,7 +372,7 @@ async function liveSnapshot(): Promise<LiveSnap> {
371
372
  async function paneTitles(): Promise<Map<string, string>> {
372
373
  const m = new Map<string, string>()
373
374
  let out = ''
374
- try { out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_title}']) } catch { return m }
375
+ try { out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_title}'], TMUX_PROBE_TIMEOUT_MS) } catch { return m }
375
376
  for (const line of out.split('\n')) {
376
377
  const tab = line.indexOf('\t'); if (tab < 0) continue
377
378
  const id = line.slice(0, tab), title = line.slice(tab + 1)
@@ -437,8 +438,8 @@ export function selfSummary(paneTitle: string): string | null {
437
438
  const launchedAt = new Map<string, number>()
438
439
  const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
439
440
  // window (~3 attempts) so a relaunching session reads 'starting', not 'offline'
440
- const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this — the
441
- // launcher daemon-not-ready race fails in ~8s; a real session runs far longer
441
+ const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this: fast
442
+ // exit before readiness is retryable, but it is not proof of one specific cause
442
443
 
443
444
  // @@@ liveness - the orthogonal axis ([[state]]): is the agent process up, for ANY session regardless of
444
445
  // lifecycle, from a prebuilt runtime snapshot (no per-call spawn — see liveSnapshot) + the adapter's own channel
@@ -678,11 +679,80 @@ export async function sessionGraph(): Promise<{ nodes: Session[]; edges: Edge[]
678
679
  return { nodes, edges }
679
680
  }
680
681
 
682
+ // @@@ apiBase resolution - WHICH backend a client verb talks to, resolved ONCE per process with the
683
+ // source kept as a discriminant ([[remote-client]]). The core thesis: a FLAG is the only signal that is
684
+ // provably deliberate — an env var cannot tell "I exported this on the command" from "I inherited this
685
+ // from the backend that launched my shell", and that ambiguity is exactly the misroute bug (a shell
686
+ // carrying project A's SPEXCODE_API_URL silently drives every bare `spex` in project B at A's backend).
687
+ // The ladder:
688
+ // 1. explicit `--api <url>` (`--port <n>` = localhost sugar) — always wins, any verb
689
+ // 2a. WORKER (SPEXCODE_SESSION_ID present): env SPEXCODE_API_URL — the backend-injected lifeline; a
690
+ // worker's state writes must NEVER gamble on cwd discovery, so the env is not demotable by (2b)
691
+ // 2b. HUMAN (no session id): the cwd project's RECORDED live backend (`spex serve` writes it, we
692
+ // /health-probe before trusting — a dead record is ignored, never followed)
693
+ // 3. the other side as fallback (human with no live record → env; worker with no env → record)
694
+ // 4. default http://127.0.0.1:$PORT||8787
695
+ export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
696
+ export type ApiBaseInfo = { url: string; source: ApiBaseSource }
697
+ const usageError = (msg: string): Error => { const e = new Error(msg); e.name = 'UsageError'; return e }
698
+ // the explicit routing flag, read from THIS process's argv (never the environment — that's the point).
699
+ // `--port` doubles as a BIND port for serve/dashboard, so the sugar is skipped for those verbs.
700
+ function explicitApiFlag(): string | null {
701
+ const argv = process.argv
702
+ const ai = argv.indexOf('--api')
703
+ if (ai >= 0) {
704
+ const v = argv[ai + 1]
705
+ if (!v || v.startsWith('--')) throw usageError('--api expects a URL (e.g. --api http://127.0.0.1:8901)')
706
+ const withScheme = v.includes('://') ? v : `http://${v}`
707
+ try { new URL(withScheme) } catch { throw usageError(`--api: not a URL: ${v}`) }
708
+ return withScheme.replace(/\/+$/, '')
709
+ }
710
+ if (argv[2] === 'serve' || argv[2] === 'dashboard') return null // their --port is a bind port, not routing
711
+ const pi = argv.indexOf('--port')
712
+ if (pi >= 0) {
713
+ const v = argv[pi + 1]
714
+ if (!v || !Number.isInteger(Number(v))) throw usageError('--port expects an integer (localhost sugar for --api http://127.0.0.1:<n>)')
715
+ return `http://127.0.0.1:${v}`
716
+ }
717
+ return null
718
+ }
719
+ // the cwd project's recorded backend ({url,pid}, written by `spex serve` at bind time into the per-project
720
+ // runtime tier), trusted only after a live /health probe — a stale record must never swallow a command.
721
+ async function liveRecordUrl(): Promise<string | null> {
722
+ let file: string
723
+ try { file = join(runtimeRoot(), 'backend.json') } catch { return null } // cwd not in a git repo → nothing to discover
724
+ let url = ''
725
+ try { const rec = JSON.parse(readFileSync(file, 'utf8')); if (typeof rec?.url === 'string') url = rec.url.trim() } catch { return null }
726
+ if (!url) return null
727
+ const ctrl = new AbortController()
728
+ const t = setTimeout(() => ctrl.abort(), 600)
729
+ try { return (await fetch(`${url}/health`, { signal: ctrl.signal })).ok ? url : null }
730
+ catch { return null }
731
+ finally { clearTimeout(t) }
732
+ }
733
+ async function resolveApiBase(): Promise<ApiBaseInfo> {
734
+ const flag = explicitApiFlag()
735
+ if (flag) return { url: flag, source: 'flag' }
736
+ const env = process.env.SPEXCODE_API_URL?.trim() || null
737
+ if (process.env.SPEXCODE_SESSION_ID?.trim()) {
738
+ if (env) return { url: env, source: 'worker-env' }
739
+ const rec = await liveRecordUrl()
740
+ if (rec) return { url: rec, source: 'record' }
741
+ } else {
742
+ const rec = await liveRecordUrl()
743
+ if (rec) return { url: rec, source: 'record' }
744
+ if (env) return { url: env, source: 'env-fallback' }
745
+ }
746
+ return { url: `http://127.0.0.1:${process.env.PORT || 8787}`, source: 'default' }
747
+ }
748
+ let apiBaseMemo: Promise<ApiBaseInfo> | null = null
749
+ export const apiBaseInfo = (): Promise<ApiBaseInfo> => (apiBaseMemo ??= resolveApiBase())
750
+ export const apiBase = async (): Promise<string> => (await apiBaseInfo()).url
751
+
681
752
  // @@@ watch registration (CLIENT side) - a `spex watch` process is separate from the server, so it
682
753
  // REPORTS itself to the backend's registration store over HTTP: register+heartbeat while it runs,
683
754
  // deregister on exit (see cli.ts `watch`). All best-effort — if the backend is down the watch still
684
755
  // streams its events; the graph edge just won't appear until a heartbeat lands. Never throws.
685
- export const apiBase = () => process.env.SPEXCODE_API_URL || `http://127.0.0.1:${process.env.PORT || 8787}`
686
756
  // the agent's OWN session id from the HARNESS env var — the public name used across cli.ts/sessions.ts.
687
757
  // Single adapter-routed impl lives in layout.ts (`envSessionId`, iterating each adapter's sessionEnvVar);
688
758
  // re-exported here so callers keep one name. Used by `spex watch` + the agent-typed `spex session …`
@@ -708,7 +778,7 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
708
778
  }
709
779
  async function postJSON(path: string, body: unknown): Promise<void> {
710
780
  try {
711
- await fetch(`${apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
781
+ await fetch(`${await apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
712
782
  } catch { /* best-effort: backend may be down; the next heartbeat / TTL reconciles */ }
713
783
  }
714
784
  export const reportWatch = (token: string, watcher: string, selectors: string[], ttlMs: number): Promise<void> =>
@@ -769,28 +839,28 @@ export function launcherCmd(rec: SessRec): string | undefined {
769
839
  if (rec.launchCmd) return rec.launchCmd
770
840
  return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined
771
841
  }
772
- function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
842
+ export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
773
843
  const file = join(storeDir(id), 'launch.sh')
774
844
  // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
775
845
  // createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
776
846
  // agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
777
847
  // `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
778
- // ambient default so resume reuses the same auth, else undefined the unnamed global resolution.
848
+ // ambient default so resume reuses the same auth. Undefined is only for old records before launch_cmd existed.
779
849
  const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`
780
- // Bounded relaunch on a FAST exit: the agent launcher (e.g. the reclaude daemon) can lose a startup
781
- // race ("daemon did not become ready") and exit within seconds before the rendezvous socket ever
782
- // appears a fast exit is the race, not real work, so retry a few times. Once the agent has run past
783
- // LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal session end
784
- // and is NEVER retried the loop exits. BOOT_GRACE_MS and SOCKET_READY_TIMEOUT_MS both span this retry
785
- // window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This only
786
- // closes the startup race — it adds no fallback and never masks a genuinely dead agent (3 attempts, then give up).
850
+ // Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
851
+ // ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
852
+ // has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
853
+ // session end and is NEVER retried the loop exits. BOOT_GRACE_MS and SOCKET_READY_TIMEOUT_MS both span this
854
+ // retry window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This
855
+ // only closes startup unready failures it adds no fallback and never masks a genuinely dead agent (3
856
+ // attempts, then give up).
787
857
  writeFileSync(file, [
788
858
  `for __spex_try in 1 2 3; do`,
789
859
  ` __spex_t0=$SECONDS`,
790
860
  ` ${invocation}`,
791
861
  ` __spex_rc=$?`,
792
862
  ` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
793
- ` printf '[spex launch] attempt %s exited in %ss (rc=%s) likely a launcher daemon race; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
863
+ ` printf '[spex launch] attempt %s exited in %ss (rc=%s) - fast launcher exit before readiness; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
794
864
  ` sleep 2`,
795
865
  `done`,
796
866
  `exit $__spex_rc`,
@@ -897,41 +967,50 @@ export function superviseQueue(intervalMs = 3000): void {
897
967
  void tick()
898
968
  }
899
969
 
900
- // @@@ assertProjectMatch - the launch is PROJECT-BOUND, but routing is by URL. `spex new`'s intent is "create a
901
- // session for the project my cwd is in", yet `apiBase()` is SPEXCODE_API_URL||PORT||8787 a pure URL carrying
902
- // no project identity. The backend it answers builds the worktree under ITS OWN mainRoot, so a stale inherited
903
- // SPEXCODE_API_URL (e.g. pointing at another repo's backend) silently lands the session in the WRONG repo. Read/
904
- // control verbs deliberately point anywhere (viewer-points-anywhere, see remote-client); only the mutating
905
- // launch is bound to the caller's project. So before launching, compare the caller's repo root to the backend's
906
- // served root and FAIL LOUD on a provable, same-host mismatch — never a silent misroute. The guard fires only on
907
- // a positive mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local
908
- // path (a genuinely remote backend) all fall through to allow, so legit remote dispatch is untouched.
909
- async function assertProjectMatch(): Promise<void> {
970
+ // @@@ assertProjectMatch - a WRITE is PROJECT-BOUND, but routing is by URL. A mutating verb's intent is
971
+ // "act on the project my cwd is in", yet the resolved base is a pure URL carrying no project identity —
972
+ // the backend it answers acts on ITS OWN mainRoot, so a stale inherited SPEXCODE_API_URL (pointing at
973
+ // another repo's backend) silently lands the write in the WRONG repo. Read/control-READS deliberately
974
+ // point anywhere (viewer-points-anywhere, see remote-client); every MUTATING verb (new/merge/send/close/
975
+ // rename/rawkey/reopen/exit) is bound to the caller's project. So before writing, compare the caller's
976
+ // repo root to the backend's served root and FAIL LOUD on a provable, same-host mismatch — never a silent
977
+ // misroute. An explicit `--api`/`--port` flag SKIPS the guard: the flag is the one provably-deliberate
978
+ // cross-project signal (that's the whole flag-beats-env thesis). The guard fires only on a positive
979
+ // mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local path
980
+ // (a genuinely remote backend) all fall through to allow, so legit remote drive stays untouched.
981
+ export async function assertProjectMatch(verb: string): Promise<void> {
982
+ const { url, source } = await apiBaseInfo()
983
+ if (source === 'flag') return // explicitly routed — the caller named the target
910
984
  let localMain: string
911
985
  try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
912
986
  let served: string | null = null
913
987
  try {
914
- const r = await fetch(`${apiBase()}/api/layout`)
988
+ const r = await fetch(`${url}/api/layout`)
915
989
  if (r.ok) served = (await r.json() as { main?: string }).main ?? null
916
- } catch { return } // backend unreachable → the POST surfaces it (fail-loud there)
990
+ } catch { return } // backend unreachable → the write itself surfaces it (fail-loud there)
917
991
  if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
918
992
  let backendMain: string
919
993
  try { backendMain = realpathSync(served) } catch { return } // backend root not a local path → a remote backend, allow
920
- if (backendMain !== localMain)
921
- throw new Error(
922
- `spex new: refusing — cwd is in ${localMain} but the backend at ${apiBase()} serves ${backendMain}.\n` +
923
- `Start this project's backend: cd ${localMain} && spex serve (or point SPEXCODE_API_URL at it)`)
994
+ if (backendMain !== localMain) {
995
+ const e = new Error(
996
+ `${verb}: refusing WRITE — cwd is in ${localMain} but the backend at ${url} serves ${backendMain}.\n` +
997
+ `Name the target explicitly (--api <url> / --port <n>) to write cross-project on purpose,\n` +
998
+ `or run this project's own backend: cd ${localMain} && spex serve. (Reads stay unguarded.)`)
999
+ e.name = 'GuardError'
1000
+ throw e
1001
+ }
924
1002
  }
925
1003
 
926
1004
  // @@@ createSession (dispatch via backend) - `spex new` / `spex session new` must launch the worker in the
927
- // BACKEND's process, not the caller's. The backend owns the launch env (notably SPEXCODE_CLAUDE_CMD, which
928
- // reclaude strips from agent envs) AND the concurrency cap. An agent that runs `spex new` (e.g. a supervisor)
929
- // has a stripped env, so an in-process launch would spawn workers under plain `claude` and 401 at boot. So
930
- // the CLI POSTs to the running backend whenever one answers, making the backend the single owner of session
931
- // launching. Only when NO backend is reachable do we fall back to launching in this process (with a stderr
932
- // warning) the backend's own POST handler calls newSession directly, so it never re-enters this path.
933
- export async function createSession(node: string | null, prompt: string, harness: string = defaultHarness.id, launcher?: string): Promise<Session> {
934
- await assertProjectMatch()
1005
+ // BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
1006
+ // launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex new` (e.g. a supervisor) would
1007
+ // bypass that queue and the maxActive gate. (The launch COMMAND is not a process-env concern anymore it
1008
+ // comes from the session's pinned launcher, resolved from project config [[launcher-select]], identical in
1009
+ // either process.) So the CLI POSTs to the running backend whenever one answers. Only when NO backend is
1010
+ // reachable do we fall back to launching in this process (with a stderr warning) the backend's own POST
1011
+ // handler calls newSession directly, so it never re-enters this path.
1012
+ export async function createSession(node: string | null, prompt: string, launcher?: string): Promise<Session> {
1013
+ await assertProjectMatch('spex new')
935
1014
  // @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
936
1015
  // via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
937
1016
  // backend, whose process env carries no acting session id. An agent that runs `spex new` stamps its own id;
@@ -939,16 +1018,23 @@ export async function createSession(node: string | null, prompt: string, harness
939
1018
  const parent = ownSessionId()
940
1019
  let res: Response
941
1020
  try {
942
- res = await fetch(`${apiBase()}/api/sessions`, {
1021
+ res = await fetch(`${await apiBase()}/api/sessions`, {
943
1022
  method: 'POST',
944
1023
  headers: { 'content-type': 'application/json' },
945
- body: JSON.stringify({ node, prompt, harness, parent, launcher }),
1024
+ body: JSON.stringify({ node, prompt, parent, launcher }),
946
1025
  })
947
1026
  } catch {
948
1027
  console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
949
- return newSession(node, prompt, harness, parent, launcher)
1028
+ return newSession(node, prompt, parent, launcher)
1029
+ }
1030
+ if (!res.ok) {
1031
+ const text = await res.text().catch(() => '')
1032
+ let msg = text
1033
+ try { msg = JSON.parse(text).error || text } catch {}
1034
+ const err = new Error(`backend rejected session (${res.status}): ${msg}`)
1035
+ err.name = 'BackendError'
1036
+ throw err
950
1037
  }
951
- if (!res.ok) throw new Error(`backend rejected session (${res.status}): ${await res.text().catch(() => '')}`)
952
1038
  return await res.json() as Session
953
1039
  }
954
1040
 
@@ -959,16 +1045,14 @@ export async function createSession(node: string | null, prompt: string, harness
959
1045
  // launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
960
1046
  // only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
961
1047
  // programmatic surface, every other surface is prompt only).
962
- export async function newSession(node: string | null, prompt: string, harness: string = defaultHarness.id, parent: string | null = null, launcher?: string): Promise<Session> {
1048
+ export async function newSession(node: string | null, prompt: string, parent: string | null = null, launcher?: string): Promise<Session> {
963
1049
  const id = randomUUID()
964
- // a named launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below, re-resolved at each
965
- // launch so resume reuses the same auth) AND the harness so picking one SUBSUMES the harness axis. Explicit
966
- // --launcher wins, else the configured defaultLauncher; an unknown name throws fail-loud (never a silent
967
- // wrong-auth launch). No launcher the harness arg (the zero-config path) with a null launcher field → the
968
- // unnamed global command resolution at launch.
969
- const lname = launcher ?? readConfig(mainRoot()).sessions?.defaultLauncher ?? null
970
- const chosen = lname ? resolveLauncher(lname) : null
971
- const h = harnessById(chosen ? chosen.harness : harness) // throws on an unknown id — fail loud, never silently launch the wrong harness
1050
+ // a launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below) AND the harness — so
1051
+ // picking one is the ONLY launch choice. Explicit --launcher wins, else the configured defaultLauncher.
1052
+ // A missing/unknown default throws fail-loud; there is no built-in-claude or harness fallback.
1053
+ const lname = launcher ?? defaultLauncher(mainRoot())
1054
+ const chosen = resolveLauncher(lname)
1055
+ const h = harnessById(chosen.harness)
972
1056
  // node identity + label: explicit --node wins, else the prompt's first `[[id]]` topic ref; a prompt with
973
1057
  // none is node-agnostic and labeled by its first few words.
974
1058
  const ref = node || mentionedNode(prompt)
@@ -988,11 +1072,11 @@ export async function newSession(node: string | null, prompt: string, harness: s
988
1072
  // mutated after. A self-parent (a resolver quirk) is dropped so a session can't nest under itself.
989
1073
  node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
990
1074
  status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
991
- harness: h.id, harnessSessionId: null, launcher: lname,
1075
+ harness: h.id, harnessSessionId: null, launcher: chosen.name,
992
1076
  // PIN the resolved base launcher command NOW ([[launcher-select]] resume-launcher-pin) so every future
993
1077
  // (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
994
1078
  // re-resolving against a default that may have flipped (a backend restarted under a different launcher).
995
- launchCmd: h.baseCmd(chosen?.cmd),
1079
+ launchCmd: h.baseCmd(chosen.cmd),
996
1080
  }
997
1081
  writeRecord(rec)
998
1082
  writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
@@ -1104,7 +1188,7 @@ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?:
1104
1188
  })
1105
1189
  return true
1106
1190
  }
1107
- export const markDone = (proposal: Proposal = 'nothing', sessionId?: string) => markState('awaiting', { proposal, sessionId })
1191
+ export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
1108
1192
  export const markError = (sessionId?: string) => markState('error', { sessionId })
1109
1193
  export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
1110
1194
  const id = sessionId || ownSessionId()
@@ -1184,6 +1268,7 @@ export type ReviewGates = {
1184
1268
  }
1185
1269
  export type ReviewPayload = {
1186
1270
  id: string; node: string | null; branch: string | null
1271
+ label: string // the session's identity, derived ONCE via deriveLabel — the review surface renders THIS, never its own node||branch||id chain
1187
1272
  ahead: number // commits the node branch is ahead of main
1188
1273
  dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
1189
1274
  diff: ReviewDiffFile[] // the worker's real changes, anchored at the merge-base
@@ -1250,6 +1335,7 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
1250
1335
  const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
1251
1336
  return {
1252
1337
  id, node: wt.rec.node, branch: wt.branch,
1338
+ label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
1253
1339
  ahead: Number(aheadOut.trim()) || 0,
1254
1340
  dirtyNonRuntime, diff,
1255
1341
  gates: { conflictsWithMain, lint },
@@ -1382,8 +1468,9 @@ export function matchesSelector(s: Session, q: string): boolean {
1382
1468
  // names the session, so `watch a,b` and `watch a b` are equivalent. A single name is the one-part case. This
1383
1469
  // is what stops a comma-joined selector from silently matching nothing — an id/node/branch never holds a
1384
1470
  // comma, so without the split `a,b` would be one literal selector that matches no session and streams in
1385
- // silence forever.
1386
- return q.split(',').map((p) => p.trim()).filter(Boolean)
1471
+ // silence forever. Each part sheds an optional reference sigil (stripRefSigil): `@<sel>` / `[[<sel>]]` name
1472
+ // the same session as the bare token, so the dashboard's mention grammar is tolerated in every CLI selector.
1473
+ return q.split(',').map((p) => stripRefSigil(p.trim())).filter(Boolean)
1387
1474
  .some((p) => s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
1388
1475
  }
1389
1476
 
@@ -1404,7 +1491,8 @@ export function selectSessions(all: Session[], selectors: string[], statuses?: s
1404
1491
  // otherwise a lone match is `ok`, several is `ambiguous` (a prefix/node hitting many), none is `none`.
1405
1492
  export type Resolved = { ok: Session } | { ambiguous: Session[] } | { none: true }
1406
1493
  export function resolveSession(selector: string, sessions: Session[]): Resolved {
1407
- const exact = sessions.find((s) => s.id === selector)
1494
+ // the exact-id check sheds the optional sigil too, so `@<full-id>` keeps the exact-wins-over-prefix rule
1495
+ const exact = sessions.find((s) => s.id === stripRefSigil(selector))
1408
1496
  if (exact) return { ok: exact }
1409
1497
  const hits = sessions.filter((s) => matchesSelector(s, selector))
1410
1498
  if (hits.length === 1) return { ok: hits[0] }
@@ -1412,6 +1500,9 @@ export function resolveSession(selector: string, sessions: Session[]): Resolved
1412
1500
  }
1413
1501
 
1414
1502
  const trunc = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s)
1503
+ // the board table's NOTE display cap \u2014 exported so the declaration echo (cli.ts) can tell an author
1504
+ // exactly where their note gets cut, instead of the cap living as an anonymous magic number here.
1505
+ export const NOTE_BOARD_LIMIT = 50
1415
1506
  // short display label per status (only close-pending differs from the status name) \u2014 used by the legend.
1416
1507
  const SHORT: Partial<Record<DisplayStatus, string>> = { 'close-pending': 'close' }
1417
1508
 
@@ -1438,7 +1529,7 @@ export function formatTable(sessions: Session[], color = true): string {
1438
1529
  const st = s.status.padEnd(13)
1439
1530
  const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
1440
1531
  const prompt = c('90', (s.promptPreview ? trunc(s.promptPreview, 40) : '').padEnd(42)) // what it was asked to do
1441
- const note = s.note ? c('90', trunc(s.note, 50)) : ''
1532
+ const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : ''
1442
1533
  return ` ${c(code, g)} ${c(code, st)} ${name} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
1443
1534
  })
1444
1535
  return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
@@ -1571,7 +1662,7 @@ export async function sendKeys(id: string, text: string, from?: string): Promise
1571
1662
  // socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
1572
1663
  // agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
1573
1664
  // move, ←/→ to adjust, Enter to set, `s` for this-session, Esc to cancel). When the agent is in that
1574
- // keystroke-navigation state its input box is replaced by the menu, so the dashboard's nav mode forwards
1665
+ // keystroke-navigation state its input box is replaced by the menu, so the dashboard's type mode forwards
1575
1666
  // each key here in real time. send-keys is exactly right for single raw keys: named keys map to tmux's own
1576
1667
  // key names; a single printable char is sent literally (`-l`) so tmux doesn't reinterpret it. The dashboard
1577
1668
  // also drives the agent with MODIFIER COMBOS — a terminal's three modifiers carried as a `C-`/`M-`/`S-`
@@ -149,19 +149,31 @@ async function rawsAsync(): Promise<Raw[]> {
149
149
  return acc
150
150
  }
151
151
 
152
- // spec node(s) that GOVERN a file by the claim rule (exact path, dir-prefix, or *-glob); reads only
153
- // frontmatter `code:` (cheap, no git) so a per-edit hook can call it. See [[governed-related]].
154
- export function specOwners(file: string): { id: string; desc: string }[] {
152
+ // the claim rule shared by both relations (exact path, dir-prefix, or *-glob). See [[governed-related]].
153
+ function claimMatcher(file: string): (cf: string) => boolean {
155
154
  const rel = file.startsWith('/') ? relative(ROOT, file) : file
156
- const claims = (cf: string): boolean => {
155
+ return (cf: string): boolean => {
157
156
  if (cf === rel) return true
158
157
  if (rel.startsWith(cf.replace(/\/+$/, '') + '/')) return true
159
158
  if (cf.includes('*')) return new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$').test(rel)
160
159
  return false
161
160
  }
161
+ }
162
+
163
+ // spec node(s) that GOVERN a file (frontmatter `code:` — source of truth, drives drift/yatsu); reads only
164
+ // frontmatter (cheap, no git) so a per-edit hook can call it.
165
+ export function specOwners(file: string): { id: string; desc: string }[] {
166
+ const claims = claimMatcher(file)
162
167
  return raws().filter((r) => list(r.fm.code).some(claims)).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
163
168
  }
164
169
 
170
+ // spec node(s) that REFERENCE a file (frontmatter `related:` — carries coverage, never drift/yatsu):
171
+ // [[governed-related]]'s other half, same claim rule, same cheap frontmatter-only read.
172
+ export function specRelated(file: string): { id: string; desc: string }[] {
173
+ const claims = claimMatcher(file)
174
+ return raws().filter((r) => list(r.fm.related).some(claims)).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
175
+ }
176
+
165
177
  // memo fileDiffAt by (version sha + spec.md path) — a commit's patch is immutable. Keyed by path too: one
166
178
  // commit can patch several nodes' spec.md. `{hash:'',patch:''}` for an unversioned node (no git call).
167
179
  const diffCache = new Map<string, { hash: string; patch: string }>()