spexcode 0.1.0

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 (55) hide show
  1. package/bin/spex.mjs +15 -0
  2. package/dashboard-dist/assets/index-B60MILFg.js +139 -0
  3. package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
  4. package/dashboard-dist/index.html +16 -0
  5. package/package.json +35 -0
  6. package/src/board.ts +119 -0
  7. package/src/cli.ts +487 -0
  8. package/src/client.ts +102 -0
  9. package/src/gateway.ts +241 -0
  10. package/src/git.ts +492 -0
  11. package/src/guide.ts +134 -0
  12. package/src/harness.ts +674 -0
  13. package/src/hooks.ts +41 -0
  14. package/src/index.ts +233 -0
  15. package/src/init.ts +120 -0
  16. package/src/layout.ts +246 -0
  17. package/src/lint.ts +206 -0
  18. package/src/login-page.ts +79 -0
  19. package/src/materialize.ts +85 -0
  20. package/src/pty-bridge.ts +235 -0
  21. package/src/ranker.ts +129 -0
  22. package/src/resilience.ts +41 -0
  23. package/src/search.bench.mjs +47 -0
  24. package/src/search.ts +24 -0
  25. package/src/self.ts +256 -0
  26. package/src/sessions.ts +1469 -0
  27. package/src/slash-commands.ts +242 -0
  28. package/src/specs.ts +331 -0
  29. package/src/supervise.ts +158 -0
  30. package/src/uploads.ts +31 -0
  31. package/templates/hooks/pre-commit +57 -0
  32. package/templates/hooks/prepare-commit-msg +14 -0
  33. package/templates/spec/project/.config/core/idle/idle.sh +15 -0
  34. package/templates/spec/project/.config/core/idle/spec.md +13 -0
  35. package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
  36. package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
  37. package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
  38. package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
  39. package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
  40. package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
  41. package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
  42. package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
  43. package/templates/spec/project/.config/core/spec.md +13 -0
  44. package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
  45. package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
  46. package/templates/spec/project/.config/extract/spec.md +60 -0
  47. package/templates/spec/project/.config/forge-link/spec.md +9 -0
  48. package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
  49. package/templates/spec/project/.config/regroup/spec.md +25 -0
  50. package/templates/spec/project/.config/scenario/spec.md +32 -0
  51. package/templates/spec/project/.config/spec.md +15 -0
  52. package/templates/spec/project/.config/supervisor/spec.md +8 -0
  53. package/templates/spec/project/.config/tidy/spec.md +25 -0
  54. package/templates/spec/project/spec.md +19 -0
  55. package/templates/spexcode.json +5 -0
package/src/ranker.ts ADDED
@@ -0,0 +1,129 @@
1
+ // tier multipliers, name > desc > body. A doc's NAME is the strongest signal, its one-line DESC a curated
2
+ // summary (next), its BODY the weakest per-hit — but the body carries BM25 term-frequency so a doc that
3
+ // genuinely concentrates a rare word still climbs. IDF scales all three. The spread only ORDERS the tiers;
4
+ // the discriminating magnitude comes from rarity (IDF) and body-density (BM25), not these constants — which
5
+ // is why they sit in flat plateaus rather than being fitted to any case.
6
+ const W_NAME_PREFIX = 8
7
+ const W_NAME_SUBSTR = 5
8
+ const W_DESC = 3
9
+ const W_BODY = 1
10
+
11
+ // a tiny stoplist of question scaffolding + length-1 tokens, dropped so "how does the … is it …" can't drown
12
+ // the content words. Deliberately small and general — NOT tuned to any benchmark; just the function words a
13
+ // natural-language query carries that match nothing meaningful.
14
+ const STOP = new Set([
15
+ 'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'is', 'it', 'its', 'as', 'at', 'by', 'for',
16
+ 'how', 'does', 'do', 'what', 'which', 'that', 'this', 'these', 'those', 'with', 'from', 'into', 'are',
17
+ 'be', 'can', 'just', 'them', 'they', 'their', 'so', 'if', 'not', 'no', 'but', 'vs', 'us', 'we', 'you',
18
+ ])
19
+
20
+ // split on non-alphanumeric, lowercase, drop stopwords + length-1 tokens, de-dup.
21
+ export function terms(query: string): string[] {
22
+ const seen = new Set<string>()
23
+ for (const w of query.toLowerCase().split(/[^a-z0-9]+/)) {
24
+ if (w.length > 1 && !STOP.has(w)) seen.add(w)
25
+ }
26
+ return [...seen]
27
+ }
28
+
29
+ // the words of a field, lowercased — used for word-boundary (prefix-of-a-word) matching, which kills
30
+ // short-token pollution (`main` must not match inside `domain`).
31
+ function words(text: string): string[] {
32
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
33
+ }
34
+
35
+ // name match is forward-only (a chosen short field — reverse would let a plural query light up every `spec-*`
36
+ // name); desc/body match bidirectionally so a singular/plural mismatch still hits, reverse gated to words ≥3
37
+ // chars so a stray short word can't swallow a longer term (IDF neutralises the generic words it pulls in).
38
+ function nameMatch(term: string, w: string): boolean { return w.startsWith(term) }
39
+ function textMatch(term: string, w: string): boolean { return w.startsWith(term) || (w.length >= 3 && term.startsWith(w)) }
40
+
41
+ // classic BM25 tf: frequency with saturation (K1 sets how fast it saturates) and length-normalisation (B),
42
+ // both in a wide insensitive plateau. tf=0 → 0.
43
+ const K1 = 1.2
44
+ const B = 0.4
45
+ function bm25tf(tf: number, len: number, avgLen: number): number {
46
+ if (tf <= 0) return 0
47
+ return (tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * len) / (avgLen || 1)))
48
+ }
49
+
50
+ // the precomputed searchable fields of one doc (built once, reused for df, scoring, and snippet).
51
+ type Fields<T> = { ref: T; name: string; nameWords: string[]; desc: string; descWords: string[]; bodyWords: string[]; snippetText: string }
52
+
53
+ // the pre-IDF weight a term earns against one doc, picking its single best tier (three fields): a name
54
+ // word-prefix beats a name substring beats a desc hit beats a body hit. Name and desc are short, chosen
55
+ // fields → binary presence; the body carries the BM25-saturated, length-normalised frequency that
56
+ // discriminates the long ties.
57
+ function tierWeight<T>(term: string, n: Fields<T>, avgBodyLen: number): number {
58
+ if (n.nameWords.some((w) => nameMatch(term, w))) return W_NAME_PREFIX
59
+ if (n.name.includes(term)) return W_NAME_SUBSTR
60
+ if (n.descWords.some((w) => textMatch(term, w))) return W_DESC
61
+ const tf = n.bodyWords.reduce((c, w) => c + (textMatch(term, w) ? 1 : 0), 0)
62
+ return W_BODY * bm25tf(tf, n.bodyWords.length, avgBodyLen)
63
+ }
64
+
65
+ // a short single-line window of prose around the FIRST matched term, so a reader sees WHY it matched. Falls
66
+ // back to the desc (then the text head) when only the name matched. Collapsed to one line, ~window chars.
67
+ function snippetFor(text: string, desc: string, qterms: string[], window = 140): string {
68
+ const flat = text.replace(/\s+/g, ' ').trim()
69
+ const lower = flat.toLowerCase()
70
+ let at = -1
71
+ for (const t of qterms) {
72
+ const m = lower.match(new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
73
+ if (m && m.index !== undefined && (at < 0 || m.index < at)) at = m.index
74
+ }
75
+ if (at < 0) {
76
+ const fb = (desc || flat).replace(/\s+/g, ' ').trim()
77
+ return fb.length > window ? fb.slice(0, window).trimEnd() + '…' : fb
78
+ }
79
+ const start = Math.max(0, at - Math.floor(window / 3))
80
+ let s = flat.slice(start, start + window).trim()
81
+ if (start > 0) s = '…' + s
82
+ if (start + window < flat.length) s = s + '…'
83
+ return s
84
+ }
85
+
86
+ // what a caller hands in per doc: the original item (`ref`, returned verbatim) + its three text fields.
87
+ export type RankInput<T> = { ref: T; name: string; desc: string; body: string }
88
+ export type Ranked<T> = { ref: T; score: number; snippet: string }
89
+
90
+ // the shared entrypoint: sum each query term's best-tier weight × IDF, keep docs hitting ≥1 term, sort by
91
+ // score desc (stable — equal scores keep the caller's pre-sorted input order), cap to `limit` (default 10).
92
+ export function rankDocs<T>(query: string, inputs: RankInput<T>[], opts: { limit?: number } = {}): Ranked<T>[] {
93
+ const limit = opts.limit ?? 10
94
+ const qterms = terms(query)
95
+ if (!qterms.length) return []
96
+
97
+ const docs: Fields<T>[] = inputs.map((d) => {
98
+ const name = d.name.toLowerCase()
99
+ return {
100
+ ref: d.ref, name, nameWords: words(name),
101
+ desc: d.desc.toLowerCase(), descWords: words(d.desc),
102
+ bodyWords: words(d.body),
103
+ snippetText: `${d.desc}\n${d.body}`,
104
+ }
105
+ })
106
+
107
+ // IDF per query term: df = docs containing it (any field), idf = ln(N/df) — a term in every doc scores 0,
108
+ // a rare one carries the rank. Read from the corpus, not hand-set.
109
+ const N = docs.length
110
+ const avgBodyLen = docs.reduce((a, n) => a + n.bodyWords.length, 0) / (N || 1)
111
+ const idf: Record<string, number> = {}
112
+ for (const t of qterms) {
113
+ let df = 0
114
+ for (const n of docs) {
115
+ if (n.nameWords.some((w) => nameMatch(t, w)) || n.descWords.some((w) => textMatch(t, w)) || n.bodyWords.some((w) => textMatch(t, w))) df++
116
+ }
117
+ idf[t] = df > 0 ? Math.log(N / df) : 0
118
+ }
119
+
120
+ const scored: Ranked<T>[] = []
121
+ for (const n of docs) {
122
+ let score = 0
123
+ for (const t of qterms) score += tierWeight(t, n, avgBodyLen) * idf[t]
124
+ if (score <= 0) continue
125
+ scored.push({ ref: n.ref, score: Math.round(score * 100) / 100, snippet: snippetFor(n.snippetText, n.desc, qterms) })
126
+ }
127
+ scored.sort((a, b) => b.score - a.score) // stable: equal scores keep the caller's pre-sorted input order
128
+ return scored.slice(0, limit)
129
+ }
@@ -0,0 +1,41 @@
1
+ import { existsSync } from 'node:fs'
2
+
3
+ function describe(e: unknown): string {
4
+ return e instanceof Error ? e.message : String(e)
5
+ }
6
+
7
+ // run a per-worktree DETAIL read; on a throw, branch on whether the worktree DIRECTORY still exists.
8
+ // dir gone → the worktree was genuinely removed mid-read → return null so the caller omits it.
9
+ // dir present → a flaky detail read (ENOENT race on a sibling file, or a git index/ref lock under a
10
+ // concurrent merge) → return the caller's `degraded` row so an EXISTING worktree is NEVER
11
+ // dropped from the board. read-failure != non-existence.
12
+ // `dir` is the worktree path (the existence fact). Accepts a sync OR async fn — resolveLayout's overlay
13
+ // read is async, listSessions' row read is sync; `degraded` is a sync raw-facts fallback.
14
+ export async function guardWorktree<T>(dir: string, fn: () => T | Promise<T>, degraded: () => T): Promise<T | null> {
15
+ try {
16
+ return await fn()
17
+ } catch (e) {
18
+ if (!existsSync(dir)) {
19
+ console.warn(`spec-cli: worktree ${dir} gone from disk (removed mid-read), omitting: ${describe(e)}`)
20
+ return null
21
+ }
22
+ console.warn(`spec-cli: worktree ${dir} detail-read failed (transient/lock); serving degraded row: ${describe(e)}`)
23
+ return degraded()
24
+ }
25
+ }
26
+
27
+ // the last-resort process net: log an otherwise-fatal async throw and KEEP SERVING. Registering these
28
+ // handlers is itself what overrides Node's default "print the stack and exit" — so the backend rides out
29
+ // a transient race it didn't anticipate instead of dropping the public port. Idempotent (guarded) so a
30
+ // double-install in one process can't stack duplicate handlers.
31
+ let guardsInstalled = false
32
+ export function installProcessGuards(): void {
33
+ if (guardsInstalled) return
34
+ guardsInstalled = true
35
+ process.on('unhandledRejection', (reason) => {
36
+ console.error(`spec-cli: unhandledRejection kept the server alive (investigate): ${reason instanceof Error ? reason.stack : describe(reason)}`)
37
+ })
38
+ process.on('uncaughtException', (err) => {
39
+ console.error(`spec-cli: uncaughtException kept the server alive (investigate): ${err?.stack || describe(err)}`)
40
+ })
41
+ }
@@ -0,0 +1,47 @@
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.
3
+ import { execFileSync } from 'node:child_process'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dirname, join } from 'node:path'
6
+
7
+ const BIN = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'spex.mjs')
8
+
9
+ const CASES = [
10
+ ['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']],
12
+ ['main-block', 'what stops an agent from committing or merging straight into main?', ['main-guard']],
13
+ ['main-escape', 'the escape hatch that lets seeding run on the main branch', ['main-guard']],
14
+ ['inter-agent-msg', 'how do two running agent sessions send messages to each other?', ['agent-reply-channel', 'comms']],
15
+ ['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']],
17
+ ['node-status', 'what makes a node show as pending vs active vs merged vs drift?', ['spec-node-states']],
18
+ ['dashboard-backend', 'how does the dashboard reach the backend API and on which port?', ['api-endpoint']],
19
+ ['loss-measured', "how is a node's loss measured and its scenarios scored?", ['yatsu-core']],
20
+ ['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']],
22
+ ['hot-reload', 'zero-downtime backend reload without dropping connections', ['supervisor']],
23
+ ['many-owners', 'can several specs own the same code file, and what happens if too many do?', ['governed-related']],
24
+ ['active-spec-search', 'an injected sub-agent that searches specs for the agent, the spec analog of Explore', ['spec-scout']],
25
+ ]
26
+
27
+ let r1 = 0, r3 = 0, mrr = 0
28
+ const rows = []
29
+ for (const [name, query, expect] of CASES) {
30
+ const out = execFileSync('node', [BIN, 'search', query, '--json', '--limit', '10'], { encoding: 'utf8' })
31
+ const results = JSON.parse(out)
32
+ const ids = results.map((x) => x.id)
33
+ const rank = ids.findIndex((id) => expect.includes(id)) + 1 // 1-based; 0 = not found
34
+ const hit1 = rank === 1
35
+ const hit3 = rank >= 1 && rank <= 3
36
+ if (hit1) r1++
37
+ if (hit3) r3++
38
+ if (rank >= 1) mrr += 1 / rank
39
+ rows.push({ name, rank: rank || '—', top: ids.slice(0, 3).join(', '), expect: expect.join('|') })
40
+ }
41
+ const n = CASES.length
42
+ for (const row of rows) {
43
+ const mark = row.rank === 1 ? '✓1' : (typeof row.rank === 'number' && row.rank <= 3 ? '·3' : (row.rank === '—' ? '✗ ' : `·${row.rank}`))
44
+ console.log(`${mark} ${row.name.padEnd(20)} want=${row.expect.padEnd(22)} rank=${String(row.rank).padStart(2)} top3: ${row.top}`)
45
+ }
46
+ 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)}`)
package/src/search.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { loadSpecsLite } from './specs.js'
2
+ import { rankDocs, type RankInput } from './ranker.js'
3
+
4
+ export type SearchResult = { id: string; title: string; path: string; score: number; snippet: string }
5
+ export type SearchStats = { nodes: number; tokens: number; ms: number }
6
+
7
+ export async function searchSpecs(query: string, opts: { limit?: number; onStats?: (s: SearchStats) => void } = {}): Promise<SearchResult[]> {
8
+ const t0 = performance.now()
9
+ // pre-sort by (shorter id, then id): rankDocs sorts stably, so for equal-scored nodes this IS the tiebreak.
10
+ const nodes = loadSpecsLite()
11
+ .slice()
12
+ .sort((a, b) => a.id.length - b.id.length || a.id.localeCompare(b.id))
13
+ const inputs: RankInput<(typeof nodes)[number]>[] = nodes.map((s) => ({
14
+ ref: s, name: `${s.title} ${s.id}`, desc: s.desc, body: s.body,
15
+ }))
16
+ const out = rankDocs(query, inputs, { limit: opts.limit }).map((r) => ({
17
+ id: r.ref.id, title: r.ref.title, path: r.ref.path, score: r.score, snippet: r.snippet,
18
+ }))
19
+ if (opts.onStats) {
20
+ const tokens = inputs.reduce((a, i) => a + `${i.name} ${i.desc} ${i.body}`.split(/[^a-z0-9]+/i).filter(Boolean).length, 0)
21
+ opts.onStats({ nodes: inputs.length, tokens, ms: performance.now() - t0 })
22
+ }
23
+ return out
24
+ }
package/src/self.ts ADDED
@@ -0,0 +1,256 @@
1
+ // @@@ spex self - the SELF-DIAGNOSIS surface (spec-cli/self). When a user launches their OWN claude/codex
2
+ // with no SpexCode process in the launch, the workflow reaches that agent only through the files
3
+ // materialize() renders (the manifest in the global store; the in-tree contract blocks + hook shims + codex
4
+ // trust). `self` answers "is this agent actually governed, or silently running free?" — diagnosing that
5
+ // materialized contract per LAYER, looping the same HARNESSES adapter materialize renders through (so claude
6
+ // AND codex are covered with no hardcoded paths). It catches the SILENT failure: a shim whose handler is
7
+ // missing, a PATH that can't resolve `spex`, a contract that never landed. Read-only today: `doctor`,
8
+ // `contract` (print the surface:system text any agent reads), `env`. install/uninstall are STAGED (noteStaged).
9
+ import { existsSync, readFileSync, accessSync, constants } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { execFileSync } from 'node:child_process'
13
+ import { homedir } from 'node:os'
14
+ import { loadSystemConfig } from './specs.js'
15
+ import { runtimeRoot, envSessionId, readAliasedRawRecord } from './layout.js'
16
+
17
+ // this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
18
+ // materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
19
+ const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
20
+
21
+ // run a git query in `dir`, swallowing git's own stderr — a non-repo returns null (the absence IS the signal).
22
+ function git(dir: string, args: string[]): string | null {
23
+ try { return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() }
24
+ catch { return null }
25
+ }
26
+ function repoRoot(dir: string): string | null { return git(dir, ['rev-parse', '--show-toplevel']) }
27
+ function commonHooksDir(dir: string): string | null {
28
+ const common = git(dir, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
29
+ return common ? join(common, 'hooks') : null
30
+ }
31
+ const read = (f: string): string => { try { return readFileSync(f, 'utf8') } catch { return '' } }
32
+
33
+ // @@@ contractText - the layer-2 payload: the composed `surface:system` bodies, the SAME join materialize()
34
+ // folds into each harness's contract file — so a BYOA agent reads byte-identical guidance.
35
+ function contractText(): { names: string[]; body: string } {
36
+ let cfgs: { name: string; body: string }[] = []
37
+ try { cfgs = loadSystemConfig().map((c) => ({ name: c.name, body: c.body })) } catch { /* tree-less cwd → empty */ }
38
+ return { names: cfgs.map((c) => c.name), body: cfgs.map((c) => c.body.trim()).filter(Boolean).join('\n\n') }
39
+ }
40
+
41
+ // compare an installed git hook to the shipped template: spexcode (current) | stale (older SpexCode, re-run
42
+ // `spex init`) | foreign (a real conflict) | missing. The stale/foreign split keeps a slightly-behind repo
43
+ // from reading as a CONFLICT.
44
+ type HookState = 'spexcode' | 'stale' | 'foreign' | 'missing'
45
+ function hookState(hooksDir: string | null, name: string): HookState {
46
+ if (!hooksDir) return 'missing'
47
+ const installed = join(hooksDir, name)
48
+ if (!existsSync(installed)) return 'missing'
49
+ const body = read(installed)
50
+ try { if (body === readFileSync(join(PKG_ROOT, 'templates', 'hooks', name), 'utf8')) return 'spexcode' } catch { /* fall through */ }
51
+ return /SpexCode/.test(body) ? 'stale' : 'foreign'
52
+ }
53
+
54
+ // does `bin` resolve to an executable on the inherited PATH? Returns the resolving path, or null. This is the
55
+ // precondition behind a dozen confusing symptoms — a session shell whose PATH misses the global bin dir gets
56
+ // `command not found` for spex/codex/claude (npm's global prefix landing off-PATH).
57
+ function resolveOnPath(bin: string): string | null {
58
+ for (const d of (process.env.PATH || '').split(':')) {
59
+ if (!d) continue
60
+ const p = join(d, bin)
61
+ try { accessSync(p, constants.X_OK); return p } catch { /* not here */ }
62
+ }
63
+ return null
64
+ }
65
+
66
+ // parse the materialized manifest (TAB lines: event<TAB>order<TAB>block<TAB>script) → the unique handler
67
+ // scripts, repo-relative. The dispatcher runs each as bash "<proj>/<script>".
68
+ function manifestScripts(text: string): string[] {
69
+ const out = new Set<string>()
70
+ for (const line of text.split('\n')) {
71
+ const f = line.split('\t')
72
+ if (f.length >= 4 && f[3]) out.add(f[3])
73
+ }
74
+ return [...out]
75
+ }
76
+
77
+ // ping the backend (apiBase) with a short timeout so doctor never hangs on a dead/wrong SPEXCODE_API_URL.
78
+ async function backendReachable(): Promise<{ base: string; up: boolean }> {
79
+ let base = 'http://127.0.0.1:8787'
80
+ try { base = (await import('./sessions.js')).apiBase() } catch { /* keep default */ }
81
+ const ctrl = new AbortController()
82
+ const t = setTimeout(() => ctrl.abort(), 800)
83
+ try { return { base, up: (await fetch(`${base}/api/sessions`, { signal: ctrl.signal })).ok } }
84
+ catch { return { base, up: false } }
85
+ finally { clearTimeout(t) }
86
+ }
87
+
88
+ // the headline: a per-layer report of whether the SpexCode workflow truly reaches THIS agent.
89
+ async function doctor(): Promise<number> {
90
+ const cwd = process.cwd()
91
+ const root = repoRoot(cwd)
92
+ const base = root ?? cwd // the contract files (CLAUDE.md/AGENTS.md) + hook shims live at the worktree ROOT — anchor every probe here
93
+ const adopted = existsSync(join(base, '.spec')) && existsSync(join(base, 'spexcode.json'))
94
+ // managed = THIS agent's own session is a GOVERNED record in the global store (the dashboard launcher set
95
+ // governed:true). That governed flag is the source of truth the old worktree `.session` presence only implied
96
+ // (see [[state]]); resolve the agent's id from its env and read the record — a self-launched BYOA agent has none.
97
+ const ownId = envSessionId()
98
+ const managed = !!(ownId && readAliasedRawRecord(ownId)?.governed)
99
+ const hooksDir = commonHooksDir(cwd)
100
+ const { names, body } = contractText()
101
+
102
+ const { HARNESSES } = await import('./harness.js')
103
+ type H = typeof HARNESSES[number]
104
+ // which harness is the RUNNING agent? the one whose session env var is set in this process.
105
+ const runningHarness: H | undefined = HARNESSES.find((h) => process.env[h.sessionEnvVar])
106
+
107
+ const L: string[] = []
108
+ const line = (k: string, v: string) => L.push(` ${k.padEnd(16)}: ${v}`)
109
+ L.push('spex self doctor — how the SpexCode workflow reaches this agent\n')
110
+
111
+ L.push('Agent')
112
+ line('detected', runningHarness ? `${runningHarness.id} (${runningHarness.sessionEnvVar}=${process.env[runningHarness.sessionEnvVar]})` : 'none detected (no harness session env var set)')
113
+ L.push('\nRepo')
114
+ line('spex-adopted', adopted ? 'yes (.spec/ + spexcode.json)' : root ? 'no — run `spex init`' : 'not a git repo')
115
+ line('root', base)
116
+ line('mode', managed ? 'managed worktree (backend-launched session)' : 'standalone repo (bring-your-own-agent)')
117
+
118
+ // --- preconditions: nothing downstream fires without these ---
119
+ L.push('\nPreconditions (without these nothing downstream fires)')
120
+ for (const bin of ['spex', 'claude', 'codex']) {
121
+ const at = resolveOnPath(bin)
122
+ line(`PATH ${bin}`, at ? `resolves (${at})` : 'NOT on PATH — a session shell will hit `command not found`')
123
+ }
124
+ const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex')
125
+ const codexCfg = read(join(codexHome, 'config.toml'))
126
+ const codexAuth = existsSync(join(codexHome, 'auth.json'))
127
+ line('codex auth', (/base_url|model_provider/.test(codexCfg) && codexAuth) ? `present (${codexHome})` : `incomplete (${codexHome}: config provider/base_url ${/base_url|model_provider/.test(codexCfg) ? 'ok' : 'MISSING'}, auth.json ${codexAuth ? 'ok' : 'MISSING'})`)
128
+
129
+ // --- git-hook floor: enforces for ANY agent/harness ---
130
+ const preCommit = hookState(hooksDir, 'pre-commit')
131
+ const prepare = hookState(hooksDir, 'prepare-commit-msg')
132
+ const hb = (s: HookState) => s === 'spexcode' ? 'installed (SpexCode)' : s === 'stale' ? 'OUTDATED (older SpexCode — re-run `spex init`)' : s === 'foreign' ? 'PRESENT but not SpexCode\'s (a foreign hook holds the slot)' : 'MISSING'
133
+ L.push('\nLayer 1 — git-hook floor (enforces for ANY agent)')
134
+ line('pre-commit', hb(preCommit))
135
+ line('prepare-cmsg', hb(prepare))
136
+ line('hooks dir', hooksDir ?? '—')
137
+
138
+ // --- contract: the surface:system block landed in each harness's contract file ---
139
+ L.push('\nLayer 2 — contract (surface:system text)')
140
+ line('nodes', names.length ? names.join(', ') : 'none — contract is empty')
141
+ for (const h of HARNESSES) {
142
+ const present = h.contractFiles(base).every((f) => /<!--\s*spexcode:start\s*-->/.test(read(f)))
143
+ line(`in ${h.id}`, present ? `block present (${h.contractFiles(base).map((f) => f.replace(base + '/', '')).join(', ')})` : 'NOT landed — run `spex self contract` / materialize')
144
+ }
145
+ line('view', 'spex self contract')
146
+
147
+ // --- hooks: the shim → dispatch, the manifest, and EVERY handler readable in the worktree ---
148
+ L.push('\nLayer 3 — hooks (shim → dispatch · manifest · handler-existence)')
149
+ for (const h of HARNESSES) {
150
+ const shim = read(h.shimFile(base))
151
+ line(`${h.id} shim`, /dispatch\.sh/.test(shim) ? `wired (${h.shimFile(base).replace(base + '/', '')})` : 'NOT wired (no dispatch shim)')
152
+ }
153
+ let manifestText = ''
154
+ try { manifestText = read(join(runtimeRoot(base), 'hooks-manifest')) } catch { /* non-git / no store */ }
155
+ if (!manifestText) {
156
+ line('manifest', 'MISSING from the global store — materialize never ran (hooks fire but find no manifest)')
157
+ } else {
158
+ const scripts = manifestScripts(manifestText)
159
+ const missing = scripts.filter((s) => !existsSync(join(base, s)))
160
+ line('manifest', `${scripts.length} handler(s) in the global store`)
161
+ line('handlers', missing.length === 0 ? 'all readable in the worktree' : `${missing.length} MISSING in the worktree → those hooks SILENTLY NO-OP:`)
162
+ for (const m of missing) L.push(` ✗ ${m}`)
163
+ }
164
+ // codex trust
165
+ const trustPresent = codexCfg.includes(`# spexcode:trust:${base}`)
166
+ line('codex trust', trustPresent ? 'trusted_hash block present in ~/.codex/config.toml' : 'absent (codex self-launch would prompt for trust)')
167
+
168
+ // --- backend: orchestration; absent is normal for BYOA ---
169
+ const { base: backendBase, up } = await backendReachable()
170
+ L.push('\nLayer 4 — session orchestration (backend-only: dispatch · queue · comms)')
171
+ line('backend', up ? `reachable at ${backendBase}` : `not reachable at ${backendBase}`)
172
+
173
+ // --- verdict ---
174
+ L.push('\nCoverage verdict')
175
+ line('preconditions', resolveOnPath('spex') ? 'spex resolves' : 'BLOCKED — spex not on PATH (fix first)')
176
+ line('layer 1', preCommit === 'spexcode' ? 'ENFORCED' : preCommit === 'stale' ? 'ENFORCED (older — update with `spex init`)' : preCommit === 'foreign' ? 'CONFLICT (a non-SpexCode hook holds the slot)' : 'ABSENT')
177
+ line('layer 2', body.length === 0 ? 'ABSENT (no contract)' : 'see per-harness above')
178
+ line('layer 3', manifestText ? 'see handler-existence above' : 'ABSENT (no manifest — agent ungoverned)')
179
+ line('layer 4', up ? 'present' : managed ? 'EXPECTED but backend down' : 'absent (normal for bring-your-own-agent)')
180
+
181
+ // --- footprint: every artifact Spex wrote here, + any slot held by something not ours ---
182
+ L.push('\nFootprint (what Spex wrote into this environment)')
183
+ const foot: string[] = []
184
+ const ours = (s: HookState) => s === 'spexcode' || s === 'stale'
185
+ if (ours(preCommit)) foot.push(`${join(hooksDir!, 'pre-commit')} (SpexCode${preCommit === 'stale' ? ', outdated' : ''})`)
186
+ if (ours(prepare)) foot.push(`${join(hooksDir!, 'prepare-commit-msg')} (SpexCode${prepare === 'stale' ? ', outdated' : ''})`)
187
+ for (const h of HARNESSES) {
188
+ if (/dispatch\.sh/.test(read(h.shimFile(base)))) foot.push(`${h.shimFile(base)} (${h.id} hook shim)`)
189
+ for (const f of h.contractFiles(base)) if (/<!--\s*spexcode:start\s*-->/.test(read(f))) foot.push(`${f} (${h.id} contract block)`)
190
+ }
191
+ if (trustPresent) foot.push(`${join(codexHome, 'config.toml')} (codex trust block)`)
192
+ L.push(foot.length ? foot.map((f) => ` ${f}`).join('\n') : ' (nothing — Spex has written no files into this environment)')
193
+ const collisions = [preCommit, prepare].filter((s) => s === 'foreign').length
194
+ line('\ncollisions', collisions ? `${collisions} hook slot(s) held by a non-SpexCode hook` : 'none')
195
+
196
+ console.log(L.join('\n'))
197
+ return 0
198
+ }
199
+
200
+ // print the layer-2 contract so any agent/harness can be handed exactly what materialize folds in.
201
+ function contract(): number {
202
+ const { body } = contractText()
203
+ if (!body) { console.error('spex self: no surface:system nodes in this .spec tree — the contract is empty.'); return 0 }
204
+ console.log(body)
205
+ return 0
206
+ }
207
+
208
+ // raw environment facts doctor reasons over — for debugging the diagnosis itself.
209
+ function env(): number {
210
+ const cwd = process.cwd()
211
+ const facts: Record<string, string> = {
212
+ cwd,
213
+ repoRoot: repoRoot(cwd) ?? '(not a git repo)',
214
+ branch: git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']) ?? '—',
215
+ node: process.version,
216
+ CLAUDE_CODE_SESSION_ID: process.env.CLAUDE_CODE_SESSION_ID ?? '',
217
+ CODEX_THREAD_ID: process.env.CODEX_THREAD_ID ?? '',
218
+ SPEXCODE_API_URL: process.env.SPEXCODE_API_URL ?? '(default :8787)',
219
+ 'PATH spex': resolveOnPath('spex') ?? '(not on PATH)',
220
+ tmux: process.env.TMUX ? 'inside tmux' : 'no',
221
+ }
222
+ for (const [k, v] of Object.entries(facts)) console.log(`${k.padEnd(24)}: ${v}`)
223
+ return 0
224
+ }
225
+
226
+ // install/uninstall are STAGED: wiring layer-3 hooks into a standalone repo is only SAFE once the hooks
227
+ // detect a missing managed session and degrade. So the diagnosis ships first; the installer lands behind it.
228
+ function noteStaged(verb: string): number {
229
+ console.error(`spex self ${verb} is not available yet — it is staged behind the hook-degradation prerequisite
230
+ (the live hooks must detect a missing managed session and degrade before they can be safely wired into your
231
+ own agent's config). Meanwhile: \`spex self doctor\` reports your coverage, and \`spex self contract\` prints
232
+ the workflow text you can hand any agent.`)
233
+ return 2
234
+ }
235
+
236
+ function usage(): number {
237
+ console.error(`spex self — diagnose how the SpexCode workflow reaches your agent
238
+ doctor per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint (default)
239
+ contract print the surface:system contract text (hand it to any agent)
240
+ env raw environment facts the diagnosis reads
241
+ install [staged] wire the materialized contract + hooks into your agent (--agent claude, --minimal)
242
+ uninstall [staged] reverse exactly what install wrote`)
243
+ return 0
244
+ }
245
+
246
+ export async function runSelf(args: string[]): Promise<number> {
247
+ switch (args[0] ?? 'doctor') {
248
+ case 'doctor': return await doctor()
249
+ case 'contract': return contract()
250
+ case 'env': return env()
251
+ case 'install': return noteStaged('install')
252
+ case 'uninstall': return noteStaged('uninstall')
253
+ case 'help': case '--help': case '-h': return usage()
254
+ default: console.error(`spex self: unknown subcommand "${args[0]}"`); usage(); return 2
255
+ }
256
+ }