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/lint.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { readdirSync, readFileSync, existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { repoRoot, stagedFiles } from './git.js'
4
+ import { loadSpecs } from './specs.js'
5
+
6
+ export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
7
+
8
+ export type LintConfig = {
9
+ governedRoots: string[] // dirs whose source files must each be governed by a spec (coverage)
10
+ sourceExtensions: string[] // extensions coverage treats as source files
11
+ identifierExtensions: string[]// extensions the altitude bare-filename signal recognises (see IDENT below)
12
+ altitude: { lineBudget: number; charBudget: number; sizeable: number; dense: number; steps: number }
13
+ maxChildren: number // breadth budget: warn at >= this many direct children
14
+ driftErrorThreshold: number// commit-local gate HARD-BLOCKS a commit touching a node >= this many commits behind
15
+ maxOwners: number // warn when a file is governed (code:) by > this many nodes
16
+ }
17
+ const DEFAULT_CONFIG: LintConfig = {
18
+ governedRoots: ['spec-dashboard/src', 'spec-cli/src'],
19
+ sourceExtensions: ['ts', 'tsx', 'js', 'jsx'],
20
+ identifierExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'md'],
21
+ altitude: { lineBudget: 50, charBudget: 4200, sizeable: 35, dense: 1.3, steps: 3 },
22
+ maxChildren: 8,
23
+ driftErrorThreshold: 3,
24
+ maxOwners: 3,
25
+ }
26
+ export function loadConfig(root: string): LintConfig {
27
+ try {
28
+ const raw = JSON.parse(readFileSync(join(root, 'spexcode.json'), 'utf8'))
29
+ const c = raw?.lint ?? {}
30
+ return { ...DEFAULT_CONFIG, ...c, altitude: { ...DEFAULT_CONFIG.altitude, ...(c.altitude ?? {}) } }
31
+ } catch {
32
+ return DEFAULT_CONFIG // no file (or unreadable) → tuned defaults; lint is the same as before.
33
+ }
34
+ }
35
+
36
+ const SKIP_DIRS = new Set(['node_modules', 'dist', '.vite'])
37
+
38
+ function sourceFiles(root: string, rel: string, acc: string[], src: RegExp) {
39
+ const abs = join(root, rel)
40
+ if (!existsSync(abs)) return
41
+ for (const e of readdirSync(abs, { withFileTypes: true })) {
42
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) sourceFiles(root, join(rel, e.name), acc, src) }
43
+ else if (src.test(e.name)) acc.push(join(rel, e.name))
44
+ }
45
+ }
46
+
47
+ // code-identifier signals: camelCase | snake_case | foo( | `backticked` | /a/path.ext | bare file.ext. Only
48
+ // the bare-filename branch needs the extension allowlist (config, so a non-TS project recognises its own
49
+ // sources) — without it a bare `word.word` would match ordinary prose like "e.g".
50
+ function identRe(extensions: string[]): RegExp {
51
+ const ext = extensions.join('|')
52
+ return new RegExp(`[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*|\\b[a-z]+_[a-z0-9_]+\\b|\\b\\w+\\(|\`[^\`]+\`|\\/[\\w./-]+\\.\\w+|\\b[\\w-]+\\.(${ext})\\b`, 'g')
53
+ }
54
+ // step-by-step how-to phrasing: numbered steps, or sequencing connectives that walk through mechanics.
55
+ const STEP_LINE = /^\s*(\d+[.)]\s|[-*]\s*(first|then|next|finally)\b)|(^|[,;]\s*)(first|then|next|finally),/i
56
+ // returns a one-line reason naming whichever low-altitude proxy(ies) tripped (length / identifier density /
57
+ // step-by-step), or null when the body is at altitude.
58
+ function altitude(body: string, cfg: LintConfig, ident: RegExp): string | null {
59
+ const a = cfg.altitude
60
+ const lines = body.split('\n')
61
+ const nb = lines.filter((l) => l.trim()).length
62
+ const chars = body.length
63
+ // identifiers and step phrasing are read from PROSE only — a fenced code sample is acknowledged code,
64
+ // not low-altitude narration, so it inflates length but not density.
65
+ let inFence = false, signals = 0, steps = 0
66
+ for (const l of lines) {
67
+ if (/^\s*```/.test(l)) { inFence = !inFence; continue }
68
+ if (inFence || !l.trim()) continue
69
+ signals += l.match(ident)?.length ?? 0
70
+ if (STEP_LINE.test(l)) steps++
71
+ }
72
+ const density = signals / Math.max(1, nb)
73
+ const why: string[] = []
74
+ if (nb > a.lineBudget || chars > a.charBudget) why.push(`${nb} non-blank lines / ${chars} chars over budget (${a.lineBudget}/${a.charBudget})`)
75
+ if (nb > a.sizeable && density > a.dense) why.push(`code-identifier density ${density.toFixed(2)}/line over ${a.dense}`)
76
+ if (nb > a.sizeable && steps >= a.steps) why.push(`${steps} step-by-step how-to lines`)
77
+ return why.length ? why.join('; ') : null
78
+ }
79
+
80
+ export async function specLint(): Promise<Finding[]> {
81
+ const root = repoRoot()
82
+ const cfg = loadConfig(root)
83
+ const ident = identRe(cfg.identifierExtensions)
84
+ const srcRe = new RegExp(`\\.(${cfg.sourceExtensions.join('|')})$`)
85
+ const specs = await loadSpecs()
86
+ const out: Finding[] = []
87
+
88
+ // integrity + build the file -> owners map.
89
+ const owners = new Map<string, string[]>()
90
+ for (const s of specs) {
91
+ for (const f of s.code) {
92
+ if (!existsSync(join(root, f)))
93
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
94
+ owners.set(f, [...(owners.get(f) ?? []), s.id])
95
+ }
96
+ }
97
+ // a file is COVERED if any node GOVERNS (code:) or merely REFERENCES (related:) it; integrity covers both.
98
+ // `related:` is the coverage net: govern is a sharp ideally-one-file pointer, so most files are reached by
99
+ // related, not govern (see [[governed-related]]). It carries coverage but never drift/yatsu.
100
+ const claimed = new Set<string>(owners.keys())
101
+ for (const s of specs) for (const f of s.related) {
102
+ if (!existsSync(join(root, f)))
103
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing related file: ${f}` })
104
+ claimed.add(f)
105
+ }
106
+
107
+ // living: a spec body describes the node's CURRENT intent — it is not a changelog. Version history
108
+ // (every content commit, its reason/session/line-diff) is read from git and shown in the dashboard's
109
+ // recent/history tabs, so a `## vN`-style heading in the body is duplicated, drift-prone state.
110
+ // Reject it. Fence-aware — a `## v2` inside a ``` block is sample text, not a heading.
111
+ const VER_HEADING = /^#{1,6}\s+v\d+\b/
112
+ for (const s of specs) {
113
+ let inFence = false
114
+ for (const line of s.body.split('\n')) {
115
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue }
116
+ if (!inFence && VER_HEADING.test(line))
117
+ out.push({ level: 'error', rule: 'living', spec: s.id, msg: `'${s.id}' has a changelog heading "${line.trim()}" — keep the body current-state; version history lives in git (recent/history tabs)` })
118
+ }
119
+ }
120
+
121
+ // altitude: a body that re-narrates mechanics instead of stating contract/intent (WARN — soft budget).
122
+ for (const s of specs) {
123
+ const why = altitude(s.body, cfg, ident)
124
+ if (why) out.push({ level: 'warn', rule: 'altitude', spec: s.id, msg: `'${s.id}' body reads low-altitude (mechanics, not contract): ${why}` })
125
+ }
126
+
127
+ // breadth: a node with too many DIRECT children is altitude's structural twin — splitting a node to pass
128
+ // altitude shouldn't just relocate the sprawl into a wide flat fan-out (WARN — soft, advisory). Children
129
+ // are derived from the parent links loadSpecs already computes; no explicit child array to keep in sync.
130
+ const childCount = new Map<string, number>()
131
+ for (const s of specs) if (s.parent) childCount.set(s.parent, (childCount.get(s.parent) ?? 0) + 1)
132
+ for (const s of specs) {
133
+ const n = childCount.get(s.id) ?? 0
134
+ if (n >= cfg.maxChildren)
135
+ out.push({ level: 'warn', rule: 'breadth', spec: s.id, msg: `'${s.id}' has ${n} direct child nodes (>= ${cfg.maxChildren}) — is an intermediate grouping layer missing? (a flat list of genuine peers is sometimes right — ignore if so)` })
136
+ }
137
+
138
+ // coverage: every governed source file must be claimed by at least one spec.
139
+ const governed: string[] = []
140
+ for (const r of cfg.governedRoots) sourceFiles(root, r, governed, srcRe)
141
+ // no governed source found at all → the defaults name this repo's own dirs, so an adopter who never set
142
+ // lint.governedRoots would otherwise see a falsely-clean board. Make it loud and point at the knob.
143
+ if (governed.length === 0)
144
+ out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — no source files under governedRoots [${cfg.governedRoots.join(', ')}]. Set lint.governedRoots in spexcode.json to your project's source dirs.` })
145
+ for (const f of governed)
146
+ if (!claimed.has(f)) out.push({ level: 'warn', rule: 'coverage', file: f, msg: `no spec governs: ${f}` })
147
+
148
+ // too-many-owners: many nodes governing one file is ordinary composition (drift fans to each — correct,
149
+ // every owner has a stake), so this is NOT flagged. Only an OVER-owned file is a smell — governed by more
150
+ // than maxOwners nodes, it has accreted more independently-specified functionality than one file should
151
+ // hold (see [[governed-related]]). ONE summary line (not one per file — its own wall of noise): the count,
152
+ // the worst offenders, and the remedy, which blames the FILE not the ownership — SPLIT it so each governor
153
+ // reclaims its own module; or merge the nodes if they're one concern; or give it a single foundation owner.
154
+ const over = [...owners].filter(([, ids]) => ids.length > cfg.maxOwners).sort((a, b) => b[1].length - a[1].length)
155
+ if (over.length) {
156
+ const top = over.slice(0, 5).map(([f, ids]) => `${f.split('/').pop()}(${ids.length})`).join(', ')
157
+ out.push({ level: 'warn', rule: 'owners', msg: `${over.length} file(s) are governed by > ${cfg.maxOwners} nodes — each holds more separately-specified functionality than one file should. Worst: ${top}. SPLIT the file so each governor owns its own module (or merge the nodes, or give it a single foundation owner + related:).` })
158
+ }
159
+
160
+ // drift: a governed file has commits NOT yet reflected in its spec. Rigorous by git ancestry —
161
+ // loadSpecs computes `driftFiles` via `git rev-list <spec's last version>..HEAD -- <file>` (see
162
+ // commitsSince in git.ts), so each warning is "N commit(s) ahead", not a timestamp guess.
163
+ for (const s of specs) {
164
+ for (const d of s.driftFiles)
165
+ out.push({ level: 'warn', rule: 'drift', spec: s.id, file: d.file, msg: `${d.file} is ${d.behind} commit(s) ahead of spec '${s.id}' (v${s.version}) — may be stale` })
166
+ }
167
+
168
+ return out
169
+ }
170
+
171
+ export const DRIFT_GUIDANCE = `DRIFT — a governed file has moved ahead of its spec. A CHECKPOINT, not a chore: find WHERE the truth
172
+ broke along raw intent → expanded spec → code: link → code structure → implementation, fix THAT layer.
173
+
174
+ Inspect: spex lint which files, against which spec
175
+ the node's spec.md its raw source + expanded spec
176
+ git diff $(git log -1 --format=%H -- <spec.md>)..HEAD -- <file> the code delta since the spec
177
+
178
+ Diagnose, then apply the one honest remedy:
179
+ • contract changed → rewrite the spec body to the new intent, commit it (re-versions the node)
180
+ • only mechanics changed → spex ack <node> "checked — spec still valid" (give a real reason)
181
+ • implementation is WRONG → the spec is right; fix the CODE back toward it, then ack
182
+ • wrong code: link → the node shouldn't own this file (or owns it too broadly); fix frontmatter
183
+ • expanded spec ≠ raw → the spec drifted from human intent; fix the expanded spec to serve the raw
184
+ • structural mismatch → one file owned by many specs / a feature with no home: refactor so a file
185
+ maps to a node, or file an issue and link it (defer honestly)
186
+
187
+ Never patch. A reasoned ack or a real fix are recorded and re-judged at review; a blind ack is a lie.`
188
+
189
+ // commit-local: an empty staged index (CI, audit) → no blockers, drift stays advisory; non-empty → block
190
+ // only when an OWN staged file belongs to a node already >= driftErrorThreshold behind. Sub-threshold drift
191
+ // on a touched node is returned for an advisory nudge; the backlog on untouched nodes never blocks.
192
+ export async function driftGate(): Promise<{ blocked: string[]; touched: { id: string; drift: number }[]; threshold: number }> {
193
+ const root = repoRoot()
194
+ const cfg = loadConfig(root)
195
+ const staged = stagedFiles(root)
196
+ if (!staged.length) return { blocked: [], touched: [], threshold: cfg.driftErrorThreshold }
197
+ const specs = await loadSpecs()
198
+ const owners = new Map<string, string[]>()
199
+ for (const s of specs) for (const f of s.code) owners.set(f, [...(owners.get(f) ?? []), s.id])
200
+ const byId = new Map(specs.map((s) => [s.id, s]))
201
+ const ids = new Set<string>()
202
+ for (const f of staged) for (const o of owners.get(f) ?? []) ids.add(o)
203
+ const touched = [...ids].map((id) => byId.get(id)!).filter((s) => s && s.drift > 0)
204
+ .map((s) => ({ id: s.id, drift: s.drift })).sort((a, b) => b.drift - a.drift)
205
+ return { blocked: touched.filter((t) => t.drift >= cfg.driftErrorThreshold).map((t) => t.id), touched, threshold: cfg.driftErrorThreshold }
206
+ }
@@ -0,0 +1,79 @@
1
+ // @@@ login page - the gateway's gate is a DESIGNED page, not the browser's Basic-auth dialog (which can't
2
+ // be styled and feels like a 1998 intranet). Self-contained: inline CSS + SVG, zero external assets, so it
3
+ // renders before anything is authorised. Dark, calm, a single password field; an error state when the
4
+ // password is wrong. The form POSTs to /login (same-origin), which mints the auth cookie and redirects.
5
+ export function loginPage(error = false): string {
6
+ return `<!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1">
11
+ <title>SpexCode — sign in</title>
12
+ <style>
13
+ :root { color-scheme: dark; }
14
+ * { box-sizing: border-box; }
15
+ body {
16
+ margin: 0; min-height: 100vh; display: grid; place-items: center;
17
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
18
+ color: #e7ecf3;
19
+ background:
20
+ radial-gradient(900px 600px at 50% -10%, #1b2b4d 0%, rgba(27,43,77,0) 60%),
21
+ radial-gradient(700px 500px at 85% 110%, #1d3b39 0%, rgba(29,59,57,0) 55%),
22
+ #080b12;
23
+ }
24
+ .card {
25
+ width: min(92vw, 360px); padding: 38px 34px 30px;
26
+ background: linear-gradient(180deg, rgba(22,28,40,0.92), rgba(15,19,28,0.92));
27
+ border: 1px solid rgba(120,150,200,0.16); border-radius: 18px;
28
+ box-shadow: 0 30px 80px -30px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,255,255,0.04);
29
+ backdrop-filter: blur(8px);
30
+ }
31
+ .mark { display: flex; align-items: center; gap: 11px; margin-bottom: 26px; }
32
+ .mark svg { width: 30px; height: 30px; }
33
+ .mark b { font-size: 17px; font-weight: 650; letter-spacing: 0.2px; }
34
+ .mark b span { color: #6ea0ff; }
35
+ h1 { font-size: 15px; font-weight: 550; margin: 0 0 4px; }
36
+ p.sub { margin: 0 0 22px; font-size: 12.5px; line-height: 1.5; color: #8b97aa; }
37
+ label { display: block; font-size: 11px; letter-spacing: 0.4px; text-transform: uppercase; color: #8b97aa; margin: 0 0 8px; }
38
+ input {
39
+ width: 100%; padding: 12px 14px; font-size: 14px; color: #eef2f8;
40
+ background: #0c1019; border: 1px solid rgba(120,150,200,0.22); border-radius: 11px;
41
+ outline: none; transition: border-color .15s, box-shadow .15s;
42
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 2px;
43
+ }
44
+ input:focus { border-color: #4f7fe0; box-shadow: 0 0 0 3px rgba(79,127,224,0.18); }
45
+ button {
46
+ width: 100%; margin-top: 16px; padding: 12px 14px; font-size: 14px; font-weight: 600;
47
+ color: #fff; cursor: pointer; border: 0; border-radius: 11px;
48
+ background: linear-gradient(180deg, #4f86f7, #3f6fe0);
49
+ box-shadow: 0 8px 22px -8px rgba(63,111,224,0.7); transition: filter .15s, transform .05s;
50
+ }
51
+ button:hover { filter: brightness(1.07); }
52
+ button:active { transform: translateY(1px); }
53
+ .err {
54
+ margin: 0 0 16px; padding: 9px 12px; font-size: 12.5px; border-radius: 9px;
55
+ color: #ffd2cf; background: rgba(220,80,70,0.12); border: 1px solid rgba(220,80,70,0.32);
56
+ }
57
+ .foot { margin-top: 22px; font-size: 11px; color: #69748a; text-align: center; line-height: 1.5; }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <form class="card" method="POST" action="/login" autocomplete="off">
62
+ <div class="mark">
63
+ <svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
64
+ <rect x="2" y="2" width="28" height="28" rx="8" fill="#0e1626" stroke="#3f6fe0" stroke-opacity="0.5"/>
65
+ <path d="M11 12.5C11 10.6 12.6 9 14.5 9h3a3.5 3.5 0 0 1 0 7h-3a3.5 3.5 0 0 0 0 7h3c1.9 0 3.5-1.6 3.5-3.5" stroke="#6ea0ff" stroke-width="2" stroke-linecap="round"/>
66
+ </svg>
67
+ <b>Spex<span>Code</span></b>
68
+ </div>
69
+ <h1>Restricted access</h1>
70
+ <p class="sub">This is a private agent workspace. Enter the access password to continue.</p>
71
+ ${error ? '<div class="err">Incorrect password — try again.</div>' : ''}
72
+ <label for="password">Password</label>
73
+ <input id="password" name="password" type="password" autofocus required placeholder="••••••••••">
74
+ <button type="submit">Sign in</button>
75
+ <div class="foot">Trusted collaborators only.</div>
76
+ </form>
77
+ </body>
78
+ </html>`
79
+ }
@@ -0,0 +1,85 @@
1
+ import { writeFileSync, mkdirSync } from 'node:fs'
2
+ import { join, dirname, relative } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { execFileSync } from 'node:child_process'
5
+ import { loadSystemConfig, loadSkillConfig } from './specs.js'
6
+ import { compileManifest } from './hooks.js'
7
+ import { HARNESSES, writeManagedBlock } from './harness.js'
8
+ import { runtimeRoot } from './layout.js'
9
+
10
+ // @@@ materialize - the "pay-per-change" node step (≈0.85s) the cheap shell gate invokes ONLY when the
11
+ // .config content-hash moved. It renders the spec tree's surface nodes into the flat artifacts each
12
+ // consumer reads cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the
13
+ // whole system via harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it),
14
+ // (2) the CONTRACT as a managed <spexcode> block in each harness's contract file(s) — user content
15
+ // preserved, (3) the thin SHIMS (every event → dispatch.sh), (4) the per-harness TRUST (Codex's deterministic
16
+ // trusted_hash; Claude none) so the self-launch is zero-prompt. EVERY harness-specific fact is owned by the
17
+ // [[harness-adapter]] (harness.ts) — this file just loops over HARNESSES, so adding a harness adds an adapter,
18
+ // not a branch here. All writes are idempotent + scoped. The content-hash marker is stamped last.
19
+
20
+ const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
21
+ const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
22
+ const SPEX = `${join(PKG, 'node_modules', '.bin', 'tsx')} ${join(PKG, 'src', 'cli.ts')}`
23
+ // the manifest + content-hash marker render into the GLOBAL per-project store (layout.runtimeRoot), NOT the
24
+ // worktree — the worktree keeps zero SpexCode-rendered runtime; only the harness-discovered contract files +
25
+ // shims (which the harness must find in-tree) are written under proj below.
26
+
27
+ // the deterministic content fingerprint of the config roots. ONE definition — `hp_config_hash` in the shell
28
+ // mirror (harness.sh) — which the dispatch.sh gate ALSO calls, so the gate and this renderer can never disagree
29
+ // on "changed" (they used to inline the identical find-pipeline in two places, each commenting the other "MUST match").
30
+ export function contentHash(proj: string): string {
31
+ try {
32
+ const harnessSh = join(PKG, 'hooks', 'harness.sh')
33
+ return execFileSync('bash', ['-c', `cd "${proj}" && . "${harnessSh}" && hp_config_hash`]).toString().trim()
34
+ } catch { return '' }
35
+ }
36
+
37
+ // the whole pay-per-change render. proj defaults to cwd. Returns the new content-hash it stamped.
38
+ export function materialize(proj = process.cwd()): string {
39
+ const rt = runtimeRoot(proj) // global per-project store, not the worktree
40
+ mkdirSync(rt, { recursive: true })
41
+ // (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
42
+ writeFileSync(join(rt, 'hooks-manifest'), compileManifest())
43
+ // (2) the contract = the surface:system bodies, in name order, written into EACH harness's contract file(s)
44
+ // + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
45
+ const contract = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean).join('\n\n')
46
+ // a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
47
+ // over the body instructions. One pure render shared by every harness — divergence is only its skillDir.
48
+ const renderSkill = (sk: { name: string; desc: string; body: string }) =>
49
+ `---\nname: ${sk.name}\ndescription: ${JSON.stringify(sk.desc)}\n---\n\n${sk.body}\n`
50
+ const shimPaths: string[] = []
51
+ for (const h of HARNESSES) {
52
+ if (contract) for (const f of h.contractFiles(proj)) writeManagedBlock(f, contract)
53
+ const shimFile = h.shimFile(proj)
54
+ mkdirSync(dirname(shimFile), { recursive: true })
55
+ const shim = h.shim(DISPATCH, SPEX)
56
+ writeFileSync(shimFile, shim.json)
57
+ h.writeTrust(proj, shim.cmd)
58
+ shimPaths.push(relative(proj, shimFile))
59
+ }
60
+ // (6) skills - each `surface: skill` node → a SKILL.md the harness auto-discovers, written into every
61
+ // harness's own skillDir (Claude .claude/skills, Codex .codex/skills). Generated wiring, so the paths
62
+ // join the same managed .gitignore block below. A harness with no skill primitive (skillDir null) is skipped.
63
+ for (const sk of loadSkillConfig()) {
64
+ for (const h of HARNESSES) {
65
+ const dir = h.skillDir(proj); if (!dir) continue
66
+ const f = join(dir, sk.name, 'SKILL.md')
67
+ mkdirSync(dirname(f), { recursive: true })
68
+ writeFileSync(f, renderSkill(sk))
69
+ shimPaths.push(relative(proj, f)) // reuse the same managed .gitignore block
70
+ }
71
+ }
72
+ // (4b) the shims are machine-specific generated wiring (they bake this machine's absolute install path), so
73
+ // gitignore them — regenerated per-machine by this same gate. Derived from the adapters' shimFile(), not
74
+ // hardcoded; written as a managed `#` block so the user's own .gitignore is preserved. Keeps the worktree
75
+ // free of tracked machine-specific files (the contract md files stay tracked — they carry the user's prose).
76
+ // only ignore paths that live INSIDE proj. The codex hooks shim now materializes at the MAIN checkout (codex
77
+ // reads a linked worktree's hooks from the root checkout — see harness.ts); from a linked worktree that path
78
+ // escapes proj (`../…`) and is gitignored by the main checkout's OWN materialize, not the worktree's.
79
+ const ignorable = shimPaths.filter((p) => !p.startsWith('..'))
80
+ if (ignorable.length) writeManagedBlock(join(proj, '.gitignore'), ignorable.sort().join('\n'), ['# ', ''])
81
+ // (5) stamp the content-hash marker LAST (so a crash mid-render leaves it stale → re-renders next gate).
82
+ const h = contentHash(proj)
83
+ writeFileSync(join(rt, 'content-hash'), h)
84
+ return h
85
+ }
@@ -0,0 +1,235 @@
1
+ import * as pty from 'node-pty'
2
+ import type { IPty } from 'node-pty'
3
+ import { execFile } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { listSessions, alive } from './sessions.js'
6
+
7
+ const pexec = promisify(execFile)
8
+ const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
9
+ // cold fallback size for a session no viewer has ever sized (see lastFit).
10
+ const DEFAULT_COLS = 120, DEFAULT_ROWS = 40
11
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
12
+
13
+ // a viewer: anything we can push pane bytes to (a WebSocket, wrapped).
14
+ export type Viewer = { send: (data: Buffer) => void }
15
+
16
+ type Bridge = { id: string; pty: IPty; cols: number; rows: number; prewarmed: boolean; clientTty?: string; repaintToken: number }
17
+ const bridges = new Map<string, Bridge>()
18
+ // viewers keyed by session id (not the Bridge), so a subscription outlives any bridge death/respawn.
19
+ const subscribers = new Map<string, Set<Viewer>>()
20
+
21
+ // last size each viewer fitted (per session + a global fallback), so pre-warm spawns at the wanted size.
22
+ const lastFit = new Map<string, { cols: number; rows: number }>()
23
+ let lastFitAny: { cols: number; rows: number } | null = null
24
+ function prewarmSize(id: string): { cols: number; rows: number } {
25
+ return lastFit.get(id) ?? lastFitAny ?? { cols: DEFAULT_COLS, rows: DEFAULT_ROWS }
26
+ }
27
+
28
+ async function tmuxRaw(args: string[]): Promise<void> {
29
+ try { await pexec('tmux', ['-L', TMUX_SOCK, ...args]) } catch { /* best-effort */ }
30
+ }
31
+ // how many clients are attached — pre-warm skips a session a human is already in (avoids a size-fight).
32
+ async function attachedCount(id: string): Promise<number> {
33
+ try {
34
+ const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, 'display-message', '-p', '-t', id, '-F', '#{session_attached}'])
35
+ return Number(stdout.trim()) || 0
36
+ } catch { return 0 }
37
+ }
38
+
39
+ // mouse on + deep history. set -g is the server default: mouse is inherited live, history-limit applies
40
+ // only to panes created afterwards.
41
+ let optsEnsured = false
42
+ async function ensureTmuxOpts(): Promise<void> {
43
+ if (optsEnsured) return
44
+ optsEnsured = true
45
+ await tmuxRaw(['set', '-g', 'mouse', 'on'])
46
+ await tmuxRaw(['set', '-g', 'history-limit', '50000'])
47
+ }
48
+
49
+ // spawn the shared tmux client for a session (idempotent). Returns null if node-pty can't spawn.
50
+ function ensureBridge(id: string, prewarm = false): Bridge | null {
51
+ let b = bridges.get(id)
52
+ if (b) { if (prewarm) b.prewarmed = true; return b }
53
+ // spawn at the last-known viewer size so a pre-warmed bridge already matches the dashboard's pane.
54
+ const { cols, rows } = prewarmSize(id)
55
+ let p: IPty
56
+ try {
57
+ // -u + a UTF-8 LANG force this client to emit UTF-8 even when the host locale is empty (a LaunchAgent
58
+ // gives LANG="" → tmux substitutes `_` for every wide char).
59
+ p = pty.spawn('tmux', ['-u', '-L', TMUX_SOCK, 'attach-session', '-t', id], {
60
+ name: 'xterm-256color', cols, rows,
61
+ env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' } as Record<string, string>,
62
+ })
63
+ } catch { return null }
64
+ b = { id, pty: p, cols, rows, prewarmed: prewarm, repaintToken: 0 }
65
+ bridges.set(id, b)
66
+ // tmux output → broadcast as raw bytes to every viewer in `subscribers` (which survives a bridge swap).
67
+ p.onData((data) => {
68
+ const buf = Buffer.from(data, 'utf8')
69
+ for (const v of subscribers.get(id) ?? []) { try { v.send(buf) } catch { /* drop a wedged viewer */ } }
70
+ })
71
+ // attach-session exited (session died or we detached): drop the bridge, and if viewers remain kick a
72
+ // reconcile to re-bind fast instead of waiting a full tick (the kick is alive-gated + serialized).
73
+ p.onExit(() => {
74
+ if (bridges.get(id) === b) bridges.delete(id)
75
+ if ((subscribers.get(id)?.size ?? 0) > 0) kickSupervisor()
76
+ })
77
+ return b
78
+ }
79
+
80
+ function killBridge(id: string): void {
81
+ const b = bridges.get(id)
82
+ if (!b) return
83
+ bridges.delete(id)
84
+ try { b.pty.kill() } catch { /* already gone */ }
85
+ }
86
+
87
+ // a browser viewer connects: subscribe it to the (warm or fresh) bridge, then settleAndRepaint for one
88
+ // coherent frame (a refresh-client down the same pty, never a spliced capture-pane snapshot).
89
+ export function attachViewer(id: string, v: Viewer): boolean {
90
+ let s = subscribers.get(id)
91
+ if (!s) subscribers.set(id, s = new Set())
92
+ s.add(v)
93
+ const b = ensureBridge(id)
94
+ if (!b) return false // spawn failed → caller closes the socket → detachViewer prunes this subscriber
95
+ void settleAndRepaint(b)
96
+ return true
97
+ }
98
+ // our attach client's tty, matched by pid (b.pty.pid === client_pid) and cached. refresh-client must
99
+ // target OUR client so the redraw hits only the dashboard's pty, not a human sharing the same session.
100
+ async function clientTty(b: Bridge): Promise<string | null> {
101
+ if (b.clientTty) return b.clientTty
102
+ try {
103
+ const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, 'list-clients', '-t', b.id, '-F', '#{client_pid} #{client_tty}'])
104
+ for (const line of stdout.split('\n')) {
105
+ const sp = line.indexOf(' ')
106
+ if (sp > 0 && Number(line.slice(0, sp)) === b.pty.pid) return (b.clientTty = line.slice(sp + 1).trim())
107
+ }
108
+ } catch { /* client not registered yet; a size-changing open resize will repaint instead */ }
109
+ return null
110
+ }
111
+ // force a full coherent repaint of our client down the shared pty. On a fresh respawn the new client may
112
+ // not be registered yet (clientTty briefly null), so retry until it resolves, bounded (~0.5s); a newer
113
+ // token supersedes us so we never clobber a fresher size.
114
+ async function repaint(b: Bridge, token: number): Promise<void> {
115
+ for (let i = 0; i < 24; i++) {
116
+ if (token !== b.repaintToken) return
117
+ const tty = await clientTty(b)
118
+ if (tty) { await tmuxRaw(['refresh-client', '-t', tty]); return }
119
+ await sleep(20)
120
+ }
121
+ }
122
+ // tmux's actual pane geometry for our session — the ground truth we wait on before repainting.
123
+ async function paneSize(b: Bridge): Promise<{ cols: number; rows: number } | null> {
124
+ try {
125
+ const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, 'display-message', '-p', '-t', b.id, '-F', '#{pane_width}x#{pane_height}'])
126
+ const m = stdout.trim().match(/^(\d+)x(\d+)$/)
127
+ if (m) return { cols: Number(m[1]), rows: Number(m[2]) }
128
+ } catch { /* session momentarily ungettable; treat as not-yet-settled */ }
129
+ return null
130
+ }
131
+ // every (re)attach and resize routes here. A per-bridge token coalesces a burst (attach + open-time
132
+ // resize) to one run: settle, poll tmux's real pane geometry until it equals the size we asked for, then
133
+ // fire a single refresh-client. A newer token supersedes us at every checkpoint. Bounded (~0.5s).
134
+ async function settleAndRepaint(b: Bridge): Promise<void> {
135
+ const token = ++b.repaintToken
136
+ await sleep(30) // coalesce an attach+resize burst to the final size
137
+ for (let i = 0; i < 24; i++) {
138
+ if (token !== b.repaintToken) return // superseded by a newer attach/resize → let it win
139
+ const sz = await paneSize(b)
140
+ if (sz && sz.cols === b.cols && sz.rows === b.rows) break
141
+ await sleep(20)
142
+ }
143
+ if (token !== b.repaintToken) return
144
+ await repaint(b, token)
145
+ }
146
+ export function detachViewer(id: string, v: Viewer): void {
147
+ const s = subscribers.get(id)
148
+ if (!s) return
149
+ s.delete(v)
150
+ if (s.size > 0) return
151
+ // last viewer gone → drop the registry entry, then release the client unless it's kept warm. An empty
152
+ // subscriber set is the single authority for "no one watching" (used here and in the supervisor reap).
153
+ subscribers.delete(id)
154
+ const b = bridges.get(id)
155
+ if (b && !b.prewarmed) killBridge(id)
156
+ }
157
+ // raw terminal input (keystrokes + mouse) straight into the shared tmux client.
158
+ export function writeViewer(id: string, data: Buffer): void {
159
+ bridges.get(id)?.pty.write(data.toString('utf8'))
160
+ }
161
+ // a viewer fitted xterm → record the size as the last-known fit (even with no bridge yet, for pre-warm)
162
+ // and resize the shared client. Repaints even on an unchanged size (a reconnect needs the frame).
163
+ export function resizeBridge(id: string, cols: number, rows: number): void {
164
+ if (!(cols > 0 && rows > 0)) return
165
+ lastFit.set(id, { cols, rows }); lastFitAny = { cols, rows }
166
+ const b = bridges.get(id)
167
+ if (b) applySize(b, cols, rows)
168
+ }
169
+ // resize the client + repaint WITHOUT recording a viewer fit — the primitive both a real resize and the
170
+ // supervisor's pre-sizing share, so the supervisor can't clobber lastFit/lastFitAny with a stale value.
171
+ function applySize(b: Bridge, cols: number, rows: number): void {
172
+ if (cols !== b.cols || rows !== b.rows) {
173
+ b.cols = cols; b.rows = rows
174
+ try { b.pty.resize(cols, rows) } catch { /* dead pty; next fit/tick retries */ }
175
+ }
176
+ void settleAndRepaint(b)
177
+ }
178
+
179
+ // one reconcile pass: warm a bridge per live session, re-bind a watched session whose pty died, reap a
180
+ // dead+unwatched bridge. Re-bind lives here (not pty.onExit) because this pass is alive-gated and
181
+ // rate-limited, so a flaky session can't storm respawns.
182
+ async function reconcileOnce(): Promise<void> {
183
+ const live = new Set<string>()
184
+ for (const s of await listSessions()) {
185
+ if (!(await alive(s.id))) continue
186
+ live.add(s.id)
187
+ // already ours → keep warm and resize a stale warm bridge to the last-known viewer size off-screen,
188
+ // so a first open finds the pane already at its size. The size-diff guard makes a converged bridge a no-op.
189
+ const existing = bridges.get(s.id)
190
+ if (existing) {
191
+ existing.prewarmed = true
192
+ const want = prewarmSize(s.id)
193
+ if (want.cols !== existing.cols || want.rows !== existing.rows) applySize(existing, want.cols, want.rows)
194
+ continue
195
+ }
196
+ // no bridge for a live session: viewers waiting → re-bind and settleAndRepaint (nothing else re-arms an
197
+ // idle pane); else pre-warm an idle detached session, but only if no human client is already attached.
198
+ if ((subscribers.get(s.id)?.size ?? 0) > 0) {
199
+ const b = ensureBridge(s.id, true)
200
+ if (b) void settleAndRepaint(b)
201
+ } else if ((await attachedCount(s.id)) === 0) {
202
+ ensureBridge(s.id, true)
203
+ }
204
+ }
205
+ for (const [id, b] of bridges) {
206
+ if (live.has(id)) continue
207
+ if ((subscribers.get(id)?.size ?? 0) === 0) killBridge(id) // dead + unwatched → release
208
+ else b.prewarmed = false // dead but still watched → serve until they leave
209
+ }
210
+ }
211
+
212
+ // serialize reconcile passes (one running, one queued), so a burst of onExit kicks collapses to one rerun.
213
+ let reconciling = false
214
+ let reconcilePending = false
215
+ async function runReconcile(): Promise<void> {
216
+ if (reconciling) { reconcilePending = true; return }
217
+ reconciling = true
218
+ try { await reconcileOnce() } catch { /* transient git/tmux hiccup; the periodic tick retries */ }
219
+ reconciling = false
220
+ if (reconcilePending) { reconcilePending = false; void runReconcile() }
221
+ }
222
+
223
+ let supervising = false
224
+ export function superviseBridges(intervalMs = 4000): void {
225
+ if (supervising) return
226
+ supervising = true
227
+ void ensureTmuxOpts()
228
+ const tick = () => { void runReconcile(); setTimeout(tick, intervalMs) }
229
+ tick()
230
+ }
231
+
232
+ // a watched bridge's pty died — recover now instead of waiting a full tick (alive-gated + serialized).
233
+ function kickSupervisor(): void {
234
+ if (supervising) void runReconcile()
235
+ }