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
@@ -0,0 +1,242 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { repoRoot } from './git.js'
5
+
6
+ // the data behind the new-session input's `/` dropdown, computed the SAME way the chosen HARNESS computes
7
+ // its own `/` menu so the two stay in lockstep. This module is the slash surface of the [[harness-adapter]]:
8
+ // one menu builder per harness (`claudeSlashCommands` / `codexSlashCommands`), wired into the adapters in
9
+ // harness.ts. The ONLY job here: produce [{name, description, source}]. It is DECOUPLED from any execution —
10
+ // the dashboard merely inserts the chosen `/<name> ` text.
11
+ //
12
+ // The Claude list is the union of:
13
+ // · BUILT_IN - the large fixed set CC ships (seeded below from a live capture, see the comment there)
14
+ // · user - ~/.claude/commands/**/*.md (subdirs namespace as `a:b`)
15
+ // · project - <repo>/.claude/commands/**/*.md
16
+ // · skill - ~/.claude/skills/*/SKILL.md + <repo>/.claude/skills/*/SKILL.md (best-effort)
17
+ // Skills/plugins/MCP that aren't readable as files simply contribute nothing — we never guess.
18
+
19
+ export type SlashCommand = { name: string; description: string; source: 'built-in' | 'user' | 'project' | 'skill' }
20
+
21
+ // captured live from `claude --dangerously-skip-permissions` v2.1.x by typing `/` and paging the dropdown;
22
+ // version-specific — re-capture this block to refresh for a new CC version (the disk-discovered sources don't).
23
+ const BUILT_IN: ReadonlyArray<readonly [string, string]> = [
24
+ ['add-dir', 'Add a new working directory'],
25
+ ['advisor', 'Let Claude consult a stronger model at key moments'],
26
+ ['agents', 'Manage agent configurations'],
27
+ ['autofix-pr', 'Monitor and autofix any issues with the current PR'],
28
+ ['background', 'Send this session to the background and free the terminal'],
29
+ ['branch', 'Create a branch of the current conversation at this point'],
30
+ ['btw', 'Ask a quick side question without interrupting the main conversation'],
31
+ ['chrome', 'Open Claude in Chrome (beta) settings'],
32
+ ['clear', 'Start a new session with empty context; previous session stays on disk (resumable with /resume)'],
33
+ ['color', 'Set the prompt bar color for this session'],
34
+ ['compact', 'Free up context by summarizing the conversation so far'],
35
+ ['config', 'Open settings'],
36
+ ['context', 'Visualize current context usage as a colored grid'],
37
+ ['copy', "Copy Claude's last response to clipboard (or /copy N for the Nth-latest)"],
38
+ ['desktop', 'Continue the current session in Claude Desktop'],
39
+ ['diff', 'View uncommitted changes and per-turn diffs'],
40
+ ['doctor', 'Diagnose and verify your Claude Code installation and settings'],
41
+ ['effort', 'Set effort level for model usage'],
42
+ ['exit', 'Exit the CLI'],
43
+ ['export', 'Export the current conversation to a file or clipboard'],
44
+ ['fast', 'Toggle fast mode (Opus 4.8)'],
45
+ ['feedback', 'Submit feedback, report a bug, or share your conversation'],
46
+ ['focus', 'Toggle focus view: just your prompt, summary, and response'],
47
+ ['fork', 'Spawn a background agent that inherits the full conversation'],
48
+ ['goal', 'Set a goal Claude checks before stopping'],
49
+ ['help', 'Show help and available commands'],
50
+ ['hooks', 'View hook configurations for tool events'],
51
+ ['ide', 'Manage IDE integrations and show status'],
52
+ ['install-github-app', 'Set up Claude GitHub Actions for a repository'],
53
+ ['install-slack-app', 'Install the Claude Slack app'],
54
+ ['keybindings', 'Open your keyboard shortcuts file'],
55
+ ['login', 'Sign in with your Anthropic account'],
56
+ ['logout', 'Sign out from your Anthropic account'],
57
+ ['mcp', 'Manage MCP servers'],
58
+ ['memory', 'Open a memory file in your editor'],
59
+ ['mobile', 'Show QR code to download the Claude mobile app'],
60
+ ['model', 'Set the AI model for Claude Code'],
61
+ ['permissions', 'Manage allow and deny tool permission rules'],
62
+ ['plan', 'Enable plan mode or view the current session plan'],
63
+ ['plugin', 'Manage Claude Code plugins'],
64
+ ['powerup', 'Discover Claude Code features through quick interactive lessons'],
65
+ ['privacy-settings', 'View and update your privacy settings'],
66
+ ['radio', 'Listen to Claude FM lo-fi radio'],
67
+ ['recap', 'Generate a one-line session recap now'],
68
+ ['release-notes', 'View release notes'],
69
+ ['reload-plugins', 'Activate pending plugin changes in the current session'],
70
+ ['reload-skills', 'Pick up skills added or changed on disk during this session'],
71
+ ['remote-control', 'Control this session from your phone or claude.ai/code'],
72
+ ['remote-env', 'Choose the default environment for cloud agents'],
73
+ ['rename', 'Rename the current conversation'],
74
+ ['resume', 'Resume a previous conversation'],
75
+ ['rewind', 'Restore the code and/or conversation to a previous point'],
76
+ ['sandbox', 'Configure sandbox settings'],
77
+ ['skills', 'List available skills'],
78
+ ['status', 'Show Claude Code status including version, model, account, API connectivity, and tool statuses'],
79
+ ['stickers', 'Order Claude Code stickers'],
80
+ ['tasks', 'View and manage everything running in the background'],
81
+ ['teleport', 'Resume a Claude Code session from claude.ai'],
82
+ ['terminal-setup', 'Install Shift+Enter key binding for newlines'],
83
+ ['theme', 'Change the theme'],
84
+ ['tui', 'Set the terminal UI renderer (default | fullscreen)'],
85
+ ['ultraplan', 'Draft an editable plan in Claude Code on the web'],
86
+ ['ultrareview', 'Start a cloud agent that finds and verifies bugs in your branch'],
87
+ ['upgrade', 'Upgrade to Max for higher rate limits and more Opus'],
88
+ ['usage', 'Show session cost, plan usage, and activity stats'],
89
+ ['usage-credits', 'Configure usage credits to keep working when you hit a limit'],
90
+ ['voice', 'Toggle voice mode'],
91
+ ['web-setup', 'Set up Claude Code on the web with your GitHub account'],
92
+ ['workflows', 'Browse running and completed workflows'],
93
+ ]
94
+
95
+ // describe precedence (mirrors CC): a `description:` frontmatter line wins, else the first non-empty body
96
+ // line (leading `#` stripped). Frontmatter parsing is intentionally one `key: value` line.
97
+ function describe(src: string): string {
98
+ const m = src.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
99
+ const fm = m ? m[1] : ''
100
+ const body = m ? m[2] : src
101
+ for (const line of fm.split('\n')) {
102
+ const d = line.match(/^\s*description\s*:\s*(.+?)\s*$/)
103
+ if (d) return d[1].replace(/^["']|["']$/g, '')
104
+ }
105
+ for (const line of body.split('\n')) {
106
+ const t = line.replace(/^#+\s*/, '').trim()
107
+ if (t) return t
108
+ }
109
+ return ''
110
+ }
111
+
112
+ // walk a commands/ dir; name = path under it minus `.md`, subdirs joined `a:b` (CC's namespace syntax).
113
+ function scanCommands(root: string, source: 'user' | 'project', out: SlashCommand[]) {
114
+ if (!existsSync(root)) return
115
+ const walk = (dir: string) => {
116
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
117
+ const p = join(dir, e.name)
118
+ if (e.isDirectory()) walk(p)
119
+ else if (e.isFile() && e.name.endsWith('.md')) {
120
+ const name = relative(root, p).replace(/\.md$/, '').split('/').join(':')
121
+ out.push({ name, description: describe(readFileSync(p, 'utf8')), source })
122
+ }
123
+ }
124
+ }
125
+ walk(root)
126
+ }
127
+
128
+ // best-effort skills: each skill is a dir with a SKILL.md whose `name:` (or dir name) is the command.
129
+ function scanSkills(root: string, out: SlashCommand[]) {
130
+ if (!existsSync(root)) return
131
+ for (const e of readdirSync(root, { withFileTypes: true })) {
132
+ const skillFile = join(root, e.name, 'SKILL.md')
133
+ if (e.isDirectory() && existsSync(skillFile)) {
134
+ const src = readFileSync(skillFile, 'utf8')
135
+ const nm = src.match(/^---\n[\s\S]*?\nname\s*:\s*(.+?)\s*\n[\s\S]*?\n---/m)
136
+ out.push({ name: (nm ? nm[1] : e.name).trim(), description: describe(src), source: 'skill' })
137
+ }
138
+ }
139
+ }
140
+
141
+ // ordering mirrors CC: custom (user, then project), then built-in, then skills; alphabetical within each
142
+ // group. A same-named custom command shadows a built-in — dedupe keeps the higher-priority source.
143
+ const RANK: Record<SlashCommand['source'], number> = { user: 0, project: 1, 'built-in': 2, skill: 3 }
144
+
145
+ // dedupe by name keeping the higher-priority source, then sort by source rank then name.
146
+ function dedupeSort(all: SlashCommand[]): SlashCommand[] {
147
+ const byName = new Map<string, SlashCommand>()
148
+ for (const c of all) {
149
+ const prev = byName.get(c.name)
150
+ if (!prev || RANK[c.source] < RANK[prev.source]) byName.set(c.name, c)
151
+ }
152
+ return [...byName.values()].sort((a, b) => RANK[a.source] - RANK[b.source] || a.name.localeCompare(b.name))
153
+ }
154
+
155
+ export function claudeSlashCommands(): SlashCommand[] {
156
+ const home = homedir()
157
+ const repo = repoRoot()
158
+ const all: SlashCommand[] = []
159
+ scanCommands(join(home, '.claude', 'commands'), 'user', all)
160
+ scanCommands(join(repo, '.claude', 'commands'), 'project', all)
161
+ for (const [name, description] of BUILT_IN) all.push({ name, description, source: 'built-in' })
162
+ scanSkills(join(home, '.claude', 'skills'), all)
163
+ scanSkills(join(repo, '.claude', 'skills'), all)
164
+ return dedupeSort(all)
165
+ }
166
+
167
+ // @@@ CODEX_BUILT_IN seed - Codex's `/` menu, taken from the codex-rs source of the pinned version
168
+ // (tui/src/slash_command.rs, codex-cli 0.142.3): the `SlashCommand` enum in PRESENTATION order with each
169
+ // `description()`. Discovered, not guessed — the same discipline as the Claude capture; to refresh for a new
170
+ // codex, re-read that enum. We drop the ones codex itself hides from a normal session: the debug-only
171
+ // (`rollout`, `test-approval`), the explicit DO-NOT-USE (`debug-m-drop`/`debug-m-update`), and the
172
+ // windows-only `sandbox-add-read-dir`. Feature-gated commands are kept (best-effort: the menu is informational).
173
+ const CODEX_BUILT_IN: ReadonlyArray<readonly [string, string]> = [
174
+ ['model', 'choose what model and reasoning effort to use'],
175
+ ['ide', 'include current selection, open files, and other context from your IDE'],
176
+ ['permissions', 'choose what Codex is allowed to do'],
177
+ ['keymap', 'remap TUI shortcuts'],
178
+ ['vim', 'toggle Vim mode for the composer'],
179
+ ['setup-default-sandbox', 'set up elevated agent sandbox'],
180
+ ['experimental', 'toggle experimental features'],
181
+ ['approve', 'approve one retry of a recent auto-review denial'],
182
+ ['memories', 'configure memory use and generation'],
183
+ ['skills', 'use skills to improve how Codex performs specific tasks'],
184
+ ['import', 'import setup, this project, and recent chats from Claude Code'],
185
+ ['hooks', 'view and manage lifecycle hooks'],
186
+ ['review', 'review my current changes and find issues'],
187
+ ['rename', 'rename the current thread'],
188
+ ['new', 'start a new chat during a conversation'],
189
+ ['archive', 'archive this session and exit'],
190
+ ['delete', 'permanently delete this session and exit'],
191
+ ['resume', 'resume a saved chat'],
192
+ ['fork', 'fork the current chat'],
193
+ ['app', 'continue this session in Codex Desktop'],
194
+ ['init', 'create an AGENTS.md file with instructions for Codex'],
195
+ ['compact', 'summarize conversation to prevent hitting the context limit'],
196
+ ['plan', 'switch to Plan mode'],
197
+ ['goal', 'set or view the goal for a long-running task'],
198
+ ['agent', 'switch the active agent thread'],
199
+ ['side', 'start a side conversation in an ephemeral fork'],
200
+ ['btw', 'start a side conversation in an ephemeral fork'],
201
+ ['copy', 'copy last response as markdown'],
202
+ ['raw', 'toggle raw scrollback mode for copy-friendly terminal selection'],
203
+ ['diff', 'show git diff (including untracked files)'],
204
+ ['mention', 'mention a file'],
205
+ ['status', 'show current session configuration and token usage'],
206
+ ['usage', 'view account usage or use a usage limit reset'],
207
+ ['debug-config', 'show config layers and requirement sources for debugging'],
208
+ ['title', 'configure which items appear in the terminal title'],
209
+ ['statusline', 'configure which items appear in the status line'],
210
+ ['theme', 'choose a syntax highlighting theme'],
211
+ ['pets', 'choose or hide the terminal pet'],
212
+ ['mcp', 'list configured MCP tools; use /mcp verbose for details'],
213
+ ['apps', 'manage apps'],
214
+ ['plugins', 'browse plugins'],
215
+ ['logout', 'log out of Codex'],
216
+ ['quit', 'exit Codex'],
217
+ ['exit', 'exit Codex'],
218
+ ['feedback', 'send logs to maintainers'],
219
+ ['ps', 'list background terminals'],
220
+ ['stop', 'stop all background terminals'],
221
+ ['clear', 'clear the terminal and start a new chat'],
222
+ ['personality', 'choose a communication style for Codex'],
223
+ ['subagents', 'switch the active agent thread'],
224
+ ]
225
+
226
+ // @@@ codexSlashCommands - Codex's `/` menu, computed the way Codex computes its own: its built-ins (above) +
227
+ // the user's saved prompts in `$CODEX_HOME/prompts/*.md` (each filename becomes `/<name>`, codex's custom-
228
+ // prompt convention). Like the Claude builder it NEVER guesses — plugin commands that aren't readable as
229
+ // simple files contribute nothing. Presentation order: built-ins first (codex's enum order), then prompts.
230
+ export function codexSlashCommands(): SlashCommand[] {
231
+ const all: SlashCommand[] = []
232
+ for (const [name, description] of CODEX_BUILT_IN) all.push({ name, description, source: 'built-in' })
233
+ const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex')
234
+ scanCommands(join(codexHome, 'prompts'), 'user', all) // saved prompts → /<basename>
235
+ // codex built-ins keep their enum (presentation) order; user prompts sort after, alphabetical.
236
+ const seen = new Map<string, SlashCommand>()
237
+ const order: string[] = []
238
+ for (const c of all) { if (!seen.has(c.name)) order.push(c.name); if (!seen.has(c.name) || RANK[c.source] < RANK[seen.get(c.name)!.source]) seen.set(c.name, c) }
239
+ const builtins = order.filter((n) => seen.get(n)!.source === 'built-in').map((n) => seen.get(n)!)
240
+ const prompts = [...seen.values()].filter((c) => c.source !== 'built-in').sort((a, b) => a.name.localeCompare(b.name))
241
+ return [...builtins, ...prompts]
242
+ }
package/src/specs.ts ADDED
@@ -0,0 +1,331 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
+ import { join, relative, basename } from 'node:path'
3
+ import { repoRoot, historyIndex, rowsFor, statsFor, pathsStats, driftIndex, driftFor, fileDiffAt } from './git.js'
4
+
5
+ // a node is any directory under .spec holding a spec.md; its parent is the nearest ancestor that also holds one.
6
+ const ROOT = repoRoot()
7
+ const SPEC_DIR = join(ROOT, '.spec')
8
+
9
+ type FmValue = string | string[]
10
+ type Raw = { id: string; parent: string | null; relPath: string; fm: Record<string, FmValue>; body: string }
11
+
12
+ // line-based frontmatter: scalars are `key: value`; an empty key followed by `- item` lines is a list (e.g. `code:`).
13
+ function parseFrontmatter(src: string) {
14
+ const fm: Record<string, FmValue> = {}
15
+ let body = src
16
+ const m = src.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
17
+ if (m) {
18
+ let key: string | null = null
19
+ for (const line of m[1].split('\n')) {
20
+ const item = line.match(/^\s*-\s+(.*)$/)
21
+ if (item && key) {
22
+ if (!Array.isArray(fm[key])) fm[key] = fm[key] ? [fm[key] as string] : []
23
+ ;(fm[key] as string[]).push(item[1].trim())
24
+ continue
25
+ }
26
+ const i = line.indexOf(':')
27
+ if (i > 0) { key = line.slice(0, i).trim(); fm[key] = line.slice(i + 1).trim() }
28
+ }
29
+ body = m[2]
30
+ }
31
+ return { fm, body }
32
+ }
33
+
34
+ const str = (v: FmValue | undefined, d = '') => (Array.isArray(v) ? v.join(', ') : v ?? d)
35
+ const list = (v: FmValue | undefined): string[] => (Array.isArray(v) ? v : v ? [v] : [])
36
+
37
+ export type SpecParts = {
38
+ rawSource: string
39
+ expandedSpec: string
40
+ }
41
+ const PART_ALIASES: Record<string, 'rawSource' | 'expandedSpec'> = {
42
+ 'raw source': 'rawSource',
43
+ 'expanded spec': 'expandedSpec',
44
+ }
45
+ function parseParts(body: string): SpecParts | null {
46
+ const acc = { rawSource: [] as string[], expandedSpec: [] as string[] }
47
+ let cur: 'rawSource' | 'expandedSpec' | null = null
48
+ let inFence = false
49
+ let any = false
50
+ for (const line of body.split('\n')) {
51
+ const fence = /^\s*```/.test(line)
52
+ if (!inFence && !fence) {
53
+ const h2 = line.match(/^##\s+(.+?)\s*$/) // exactly two hashes — `###` won't match
54
+ if (h2) {
55
+ const key = PART_ALIASES[h2[1].trim().toLowerCase()]
56
+ if (key) { cur = key; any = true; continue }
57
+ // an unrecognized `## …` heading is just content of the current part — fall through.
58
+ }
59
+ }
60
+ if (fence) inFence = !inFence
61
+ if (cur === 'rawSource') acc.rawSource.push(line)
62
+ else if (cur === 'expandedSpec') acc.expandedSpec.push(line)
63
+ }
64
+ if (!any) return null
65
+ const t = (a: string[]) => a.join('\n').trim()
66
+ return { rawSource: t(acc.rawSource), expandedSpec: t(acc.expandedSpec) }
67
+ }
68
+
69
+ export type DerivedStatus = 'pending' | 'active' | 'merged' | 'drift'
70
+
71
+ export function deriveStatus(d: { version: number; drift: number; hasOverlay?: boolean; hasCode?: boolean; fmStatus?: string }): DerivedStatus {
72
+ if (d.fmStatus === 'pending' && !d.hasCode && d.drift === 0) return 'pending'
73
+ if (d.hasOverlay) return 'active'
74
+ if (d.drift > 0) return 'drift'
75
+ if (d.version > 0) return 'merged'
76
+ const fb = d.fmStatus
77
+ if (fb === 'active' || fb === 'merged' || fb === 'drift') return fb
78
+ return 'pending'
79
+ }
80
+
81
+ function walk(dir: string, parent: string | null, acc: Raw[]) {
82
+ let myId = parent
83
+ if (existsSync(join(dir, 'spec.md'))) {
84
+ myId = basename(dir)
85
+ const relPath = relative(ROOT, join(dir, 'spec.md'))
86
+ const { fm, body } = parseFrontmatter(readFileSync(join(dir, 'spec.md'), 'utf8'))
87
+ acc.push({ id: myId, parent, relPath, fm, body })
88
+ }
89
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
90
+ if (e.isDirectory()) walk(join(dir, e.name), myId, acc)
91
+ }
92
+ }
93
+
94
+ // re-key each node to the shortest globally-unique trailing path-suffix (overrides walk's placeholder
95
+ // basename id/parent); the second loop recomputes parent by path-ancestry.
96
+ function reId(acc: Raw[]): void {
97
+ const segs = acc.map((r) => r.relPath.split(/[/\\]/).slice(1, -1)) // path under .spec, minus 'spec.md'
98
+ const suffix = (s: string[], k: number) => s.slice(s.length - k).join('/')
99
+ for (let i = 0; i < acc.length; i++) {
100
+ const s = segs[i]
101
+ let k = 1
102
+ while (k < s.length && segs.some((o, j) => j !== i && o.length >= k && suffix(o, k) === suffix(s, k))) k++
103
+ acc[i].id = suffix(s, k)
104
+ }
105
+ for (let i = 0; i < acc.length; i++) {
106
+ let best = -1
107
+ for (let j = 0; j < acc.length; j++) {
108
+ const o = segs[j], s = segs[i]
109
+ if (j !== i && o.length < s.length && o.every((seg, x) => seg === s[x]) && (best < 0 || o.length > segs[best].length)) best = j
110
+ }
111
+ acc[i].parent = best >= 0 ? acc[best].id : null
112
+ }
113
+ }
114
+
115
+ function raws(): Raw[] {
116
+ const acc: Raw[] = []
117
+ if (existsSync(SPEC_DIR)) walk(SPEC_DIR, null, acc)
118
+ reId(acc)
119
+ return acc
120
+ }
121
+
122
+ // spec node(s) that GOVERN a file by the claim rule (exact path, dir-prefix, or *-glob); reads only
123
+ // frontmatter `code:` (cheap, no git) so a per-edit hook can call it. See [[governed-related]].
124
+ export function specOwners(file: string): { id: string; desc: string }[] {
125
+ const rel = file.startsWith('/') ? relative(ROOT, file) : file
126
+ const claims = (cf: string): boolean => {
127
+ if (cf === rel) return true
128
+ if (rel.startsWith(cf.replace(/\/+$/, '') + '/')) return true
129
+ if (cf.includes('*')) return new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$').test(rel)
130
+ return false
131
+ }
132
+ return raws().filter((r) => list(r.fm.code).some(claims)).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
133
+ }
134
+
135
+ // memo fileDiffAt by (version sha + spec.md path) — a commit's patch is immutable. Keyed by path too: one
136
+ // commit can patch several nodes' spec.md. `{hash:'',patch:''}` for an unversioned node (no git call).
137
+ const diffCache = new Map<string, { hash: string; patch: string }>()
138
+ async function latestDiff(relPath: string, hash: string): Promise<{ hash: string; patch: string }> {
139
+ if (!hash) return { hash: '', patch: '' }
140
+ const key = `${hash}\0${relPath}`
141
+ const hit = diffCache.get(key)
142
+ if (hit) return hit
143
+ const val = { hash, patch: await fileDiffAt(ROOT, relPath, hash) }
144
+ diffCache.set(key, val)
145
+ return val
146
+ }
147
+
148
+ // filesystem-only slice of a node (id/title/path/desc/body, no git) for hot lexical reads like
149
+ // [[spec-search]]; same fields loadSpecs reports, without the git history/drift walk.
150
+ export type SpecLite = { id: string; title: string; path: string; desc: string; body: string }
151
+ export function loadSpecsLite(): SpecLite[] {
152
+ return raws().map((r) => ({
153
+ id: r.id,
154
+ title: str(r.fm.title, r.id),
155
+ path: r.relPath,
156
+ desc: str(r.fm.desc),
157
+ body: r.body.trim(),
158
+ }))
159
+ }
160
+
161
+ export async function loadSpecs() {
162
+ // both indexes are one cached git walk each and independent — fetch them in parallel (async git, off
163
+ // the event loop). Every node below is then a pure lookup.
164
+ const [idx, didx] = await Promise.all([historyIndex(ROOT), driftIndex(ROOT)])
165
+ const allRaws = raws()
166
+ return Promise.all(allRaws.map(async (r) => {
167
+ const h = rowsFor(idx, r.relPath)
168
+ // session = the Session: trailer of the node's latest version; frontmatter `session:` is the fallback.
169
+ const fmSession = str(r.fm.session)
170
+ const session = h[0]?.session || (fmSession && fmSession !== 'null' ? fmSession : null)
171
+ const code = list(r.fm.code)
172
+ const S = h[0]?.hash || ''
173
+ const driftFiles = code
174
+ .map((f) => ({ file: f, behind: driftFor(didx, S, f) }))
175
+ .filter((d) => d.behind > 0)
176
+ const drift = driftFiles.reduce((a, d) => a + d.behind, 0)
177
+ const fmStatus = str(r.fm.status, '') || null
178
+ return {
179
+ id: r.id,
180
+ parent: r.parent,
181
+ path: r.relPath,
182
+ title: str(r.fm.title, r.id),
183
+ status: deriveStatus({ version: h.length, drift, hasCode: code.length > 0, fmStatus: fmStatus ?? undefined }),
184
+ fmStatus,
185
+ session,
186
+ hue: Number(str(r.fm.hue, '210')),
187
+ desc: str(r.fm.desc),
188
+ code,
189
+ related: list(r.fm.related),
190
+ version: h.length,
191
+ reason: h[0]?.reason || '',
192
+ // ISO date of the node's latest version commit (h is newest-first), or null if unversioned.
193
+ lastEdited: h[0]?.date || null,
194
+ drift,
195
+ driftFiles,
196
+ // the latest version's spec.md patch is NOT precomputed here (it cost 2 git show forks per node on
197
+ // cold load); the history tab fetches it lazily via specDiffAt. See [[work-pane]].
198
+ body: r.body.trim(),
199
+ parts: parseParts(r.body),
200
+ }
201
+ }))
202
+ }
203
+
204
+ // per-node version timeline; each row sums the node's spec.md stat (rename-followed, via statsFor) and its
205
+ // governed-code stat (pathsStats) — separate because spec.md needs rename-following a plain `git log -- path` can't do.
206
+ export async function specHistory(id: string) {
207
+ const node = raws().find((r) => r.id === id)
208
+ if (!node) return []
209
+ const codePaths = list(node.fm.code)
210
+ // index (cached) and the code-path walk are independent — run them in parallel, both async git.
211
+ const [idx, cStats] = await Promise.all([historyIndex(ROOT), pathsStats(ROOT, codePaths)])
212
+ const sStats = statsFor(idx, node.relPath)
213
+ return rowsFor(idx, node.relPath).map((v) => {
214
+ const s = sStats.get(v.hash) ?? { additions: 0, deletions: 0, files: 0 }
215
+ const c = cStats.get(v.hash) ?? { additions: 0, deletions: 0, files: 0 }
216
+ return { ...v, additions: s.additions + c.additions, deletions: s.deletions + c.deletions, files: s.files + c.files }
217
+ })
218
+ }
219
+
220
+ // the line-diff a specific version introduced to a node's spec.md, by hash; fetched lazily when a history
221
+ // item expands. fileDiffAt resolves the spec.md path AT that commit (reparents). `{hash:'',patch:''}` for
222
+ // an empty hash, null for an unknown id.
223
+ export async function specDiffAt(id: string, hash: string) {
224
+ const node = raws().find((r) => r.id === id)
225
+ if (!node) return null
226
+ if (!hash) return { hash: '', patch: '' }
227
+ return latestDiff(node.relPath, hash)
228
+ }
229
+
230
+ // config presets - REFLEXIVE, SKILL-SHAPED preset nodes whose folder IS a skill bundle: `spec.md`'s
231
+ // body is the agent prompt/contract (with a {{targets}} placeholder the launcher fills with the
232
+ // @-referenced nodes), and the SAME folder may co-locate auxiliary files — scripts, assets — that the
233
+ // preset ships for the agent to run deterministically. So each preset reports its folder `dir`
234
+ // (repo-relative) and its `files` (co-located paths, spec.md excluded) alongside name/title/desc/kind/body.
235
+ // `kind` ∈ mutating|report tells the launcher whether the preset edits the graph or only reports on it.
236
+ // `events`/`order`/`block` are populated only for the `hook` surface (empty/0/false otherwise): which
237
+ // harness lifecycle events the node binds, its deterministic intra-event order, and whether it intends to
238
+ // block (honored only on block-capable events). See loadHookConfig + the hook compiler/dispatcher.
239
+ export type ConfigPreset = { name: string; title: string; desc: string; kind: string; dir: string; files: string[]; body: string; events: string[]; order: number; block: boolean }
240
+ // field-driven surface - a config plugin is a FLAT direct child of a config root (`<root>/<name>/spec.md`)
241
+ // that carries a `surface: system|slash|hook|skill` frontmatter field naming where it plugs in. There are no
242
+ // `slash/`/`system/`/`hook/`/`skill/` bucket dirs (those were graph-invisible grouping dirs with no spec.md, so
243
+ // the spec graph skipped them — path != graph); the surface is a FIELD on the node, so the plugin is a real
244
+ // graph child of its root. BOTH config roots participate: `.config` (the instance — DIY dev-flow plugins) and
245
+ // `config` (the project system spec). loadConfig gathers the `slash` surface, loadSystemConfig the `system`
246
+ // surface, loadHookConfig the `hook` surface, loadSkillConfig the `skill` surface; each scans the children under
247
+ // every root and filters by the field. The plugins also show on the board as ordinary spec nodes (via loadSpecs).
248
+ // root node - the spec tree's single top-level node: the one directory directly under .spec/ that
249
+ // holds a spec.md. The dogfood repo names it 'spexcode'; a repo scaffolded by `spex init` names it
250
+ // 'project' (or whatever the adopter renames it to). Detected DYNAMICALLY so the config loaders resolve
251
+ // the ACTUAL root's config dirs — never a hardcoded 'spexcode', which silently returned [] in an adopter
252
+ // repo, so their .config/core contract never loaded and their launched agents got no system prompt.
253
+ // Returns null when .spec holds no such directory. (resolveLayout's `main` is a checkout PATH, not the
254
+ // root node NAME, so it can't serve this — a tiny filesystem probe is the right seam.)
255
+ function rootNode(): string | null {
256
+ if (!existsSync(SPEC_DIR)) return null
257
+ for (const e of readdirSync(SPEC_DIR, { withFileTypes: true })) {
258
+ if (e.isDirectory() && existsSync(join(SPEC_DIR, e.name, 'spec.md'))) return e.name
259
+ }
260
+ return null
261
+ }
262
+ // resolved at call time (not module-eval) so it tracks the live tree.
263
+ function configRoots(): string[] {
264
+ const root = rootNode()
265
+ if (!root) return []
266
+ return ['.config', 'config'].map((r) => join(SPEC_DIR, root, r))
267
+ }
268
+ // co-located bundle files = everything under the node folder except its spec.md, repo-relative, recursive.
269
+ function bundleFiles(dir: string): string[] {
270
+ const out: string[] = []
271
+ const walk = (d: string) => {
272
+ for (const e of readdirSync(d, { withFileTypes: true })) {
273
+ const p = join(d, e.name)
274
+ if (e.isDirectory()) walk(p)
275
+ else if (e.name !== 'spec.md') out.push(relative(ROOT, p))
276
+ }
277
+ }
278
+ walk(dir)
279
+ return out.sort()
280
+ }
281
+ // gather the preset nodes under a config root that declare `surface: <surface>`. The scan is RECURSIVE —
282
+ // `surface` is a FIELD, not a path (the design's core tenet), so a plugin may live at ANY depth and a
283
+ // grouping parent may itself be a plugin (e.g. `.config/core` is a `surface: system` contract whose CHILDREN
284
+ // are `surface: hook` nodes). The field filter keeps it safe: a node only gathers if it declares THIS
285
+ // surface, so descending past a matched node never double-counts (children carry a different surface). For
286
+ // `system`/`slash` the result is identical to the old one-level scan on the current tree — every existing
287
+ // such node is a flat direct child and no nested node declares those surfaces — so the gather set (hence
288
+ // the appended system prompt and the slash dropdown) is byte-for-byte unchanged.
289
+ function loadSurface(surface: 'slash' | 'system' | 'hook' | 'skill'): ConfigPreset[] {
290
+ const out: ConfigPreset[] = []
291
+ const visit = (nodeDir: string, name: string) => {
292
+ if (existsSync(join(nodeDir, 'spec.md'))) {
293
+ const { fm, body } = parseFrontmatter(readFileSync(join(nodeDir, 'spec.md'), 'utf8'))
294
+ // @@@ skip pending - a `status: pending` plugin is DECLARED INTENT, not yet active. It renders on the
295
+ // board (via loadSpecs) but must NOT gather: neither a slash preset, nor folded into a system prompt,
296
+ // nor a live hook. Only built/active plugins surface here, so pending stubs stay inert.
297
+ if (str(fm.surface) === surface && str(fm.status) !== 'pending') {
298
+ out.push({
299
+ name,
300
+ title: str(fm.title, name),
301
+ desc: str(fm.desc),
302
+ kind: str(fm.kind, 'mutating'),
303
+ dir: relative(ROOT, nodeDir),
304
+ files: bundleFiles(nodeDir),
305
+ body: body.trim(),
306
+ events: list(fm.events),
307
+ order: Number(str(fm.order, '0')) || 0,
308
+ block: str(fm.block) === 'true',
309
+ })
310
+ }
311
+ }
312
+ for (const e of readdirSync(nodeDir, { withFileTypes: true })) {
313
+ if (e.isDirectory()) visit(join(nodeDir, e.name), e.name)
314
+ }
315
+ }
316
+ for (const root of configRoots()) {
317
+ if (!existsSync(root)) continue
318
+ for (const e of readdirSync(root, { withFileTypes: true })) {
319
+ if (e.isDirectory()) visit(join(root, e.name), e.name)
320
+ }
321
+ }
322
+ return out.sort((a, b) => a.name.localeCompare(b.name))
323
+ }
324
+ export function loadConfig(): ConfigPreset[] { return loadSurface('slash') }
325
+ export function loadSystemConfig(): ConfigPreset[] { return loadSurface('system') }
326
+ // the hook handlers (compiled into the per-session hook manifest the dispatcher reads). Each carries its
327
+ // `events`/`order`/`block` binding + co-located script `files`.
328
+ export function loadHookConfig(): ConfigPreset[] { return loadSurface('hook') }
329
+ // the skill bundles (rendered into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
330
+ // load-trigger and its `body` is the on-demand instructions; loadSurface passes the folder basename as `name`.
331
+ export function loadSkillConfig(): ConfigPreset[] { return loadSurface('skill') }