@spexcode/spec-core 0.6.2

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.
package/src/specs.ts ADDED
@@ -0,0 +1,498 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
2
+ import { readFile, readdir } from 'node:fs/promises'
3
+ import { join, relative, basename } from 'node:path'
4
+ import { repoRoot, historyIndex, rowsFor, historyStats, pathsStats, driftIndex, driftFor, fileDiffAt,
5
+ sourceIndexes, treeTextFiles, primeAncestorClosures, ancestorsOf, inAncestors, type HistoryIndex, type DriftIndex } from './git.js'
6
+ import { parseCodeEntry, parseRelation, relationClaimsPath } from './anchors.js'
7
+
8
+ // a node is any directory under .spec holding a spec.md; its parent is the nearest ancestor that also holds one.
9
+ const ROOT = repoRoot()
10
+ const SPEC_DIR = join(ROOT, '.spec')
11
+
12
+ type FmValue = string | string[]
13
+ type Raw = { id: string; parent: string | null; relPath: string; fm: Record<string, FmValue>; body: string }
14
+
15
+ // line-based frontmatter: scalars are `key: value`; an empty key followed by `- item` lines is a list (e.g. `code:`).
16
+ export function parseFrontmatter(src: string) {
17
+ const fm: Record<string, FmValue> = {}
18
+ let body = src
19
+ const m = src.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
20
+ if (m) {
21
+ let key: string | null = null
22
+ for (const line of m[1].split('\n')) {
23
+ const item = line.match(/^\s*-\s+(.*)$/)
24
+ if (item && key) {
25
+ if (!Array.isArray(fm[key])) fm[key] = fm[key] ? [fm[key] as string] : []
26
+ ;(fm[key] as string[]).push(item[1].trim())
27
+ continue
28
+ }
29
+ const i = line.indexOf(':')
30
+ if (i > 0) { key = line.slice(0, i).trim(); fm[key] = line.slice(i + 1).trim() }
31
+ }
32
+ body = m[2]
33
+ }
34
+ return { fm, body }
35
+ }
36
+
37
+ const str = (v: FmValue | undefined, d = '') => (Array.isArray(v) ? v.join(', ') : v ?? d)
38
+ const list = (v: FmValue | undefined): string[] => (Array.isArray(v) ? v : v ? [v] : [])
39
+
40
+ export type SpecParts = {
41
+ rawSource: string
42
+ expandedSpec: string
43
+ }
44
+ const PART_ALIASES: Record<string, 'rawSource' | 'expandedSpec'> = {
45
+ 'raw source': 'rawSource',
46
+ 'expanded spec': 'expandedSpec',
47
+ }
48
+ function parseParts(body: string): SpecParts | null {
49
+ const acc = { rawSource: [] as string[], expandedSpec: [] as string[] }
50
+ let cur: 'rawSource' | 'expandedSpec' | null = null
51
+ let inFence = false
52
+ let any = false
53
+ for (const line of body.split('\n')) {
54
+ const fence = /^\s*```/.test(line)
55
+ if (!inFence && !fence) {
56
+ const h2 = line.match(/^##\s+(.+?)\s*$/) // exactly two hashes — `###` won't match
57
+ if (h2) {
58
+ const key = PART_ALIASES[h2[1].trim().toLowerCase()]
59
+ if (key) { cur = key; any = true; continue }
60
+ // an unrecognized `## …` heading is just content of the current part — fall through.
61
+ }
62
+ }
63
+ if (fence) inFence = !inFence
64
+ if (cur === 'rawSource') acc.rawSource.push(line)
65
+ else if (cur === 'expandedSpec') acc.expandedSpec.push(line)
66
+ }
67
+ if (!any) return null
68
+ const t = (a: string[]) => a.join('\n').trim()
69
+ return { rawSource: t(acc.rawSource), expandedSpec: t(acc.expandedSpec) }
70
+ }
71
+
72
+ export type DerivedStatus = 'pending' | 'active' | 'merged' | 'drift'
73
+
74
+ export function deriveStatus(d: { version: number; drift: number; hasOverlay?: boolean; hasCode?: boolean; fmStatus?: string }): DerivedStatus {
75
+ if (d.fmStatus === 'pending' && !d.hasCode && d.drift === 0) return 'pending'
76
+ if (d.hasOverlay) return 'active'
77
+ if (d.drift > 0) return 'drift'
78
+ if (d.version > 0) return 'merged'
79
+ const fb = d.fmStatus
80
+ if (fb === 'active' || fb === 'merged' || fb === 'drift') return fb
81
+ return 'pending'
82
+ }
83
+
84
+ function walk(dir: string, parent: string | null, acc: Raw[]) {
85
+ let myId = parent
86
+ if (existsSync(join(dir, 'spec.md'))) {
87
+ myId = basename(dir)
88
+ const relPath = relative(ROOT, join(dir, 'spec.md'))
89
+ const { fm, body } = parseFrontmatter(readFileSync(join(dir, 'spec.md'), 'utf8'))
90
+ acc.push({ id: myId, parent, relPath, fm, body })
91
+ }
92
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
93
+ if (e.isDirectory()) walk(join(dir, e.name), myId, acc)
94
+ }
95
+ }
96
+
97
+ // the id MINT ([[id-url-safe]]): key each node — given its path segments under .spec — to its leaf dir
98
+ // name, or on a leaf collision the shortest globally-unique trailing path-suffix. A node id is a URL-safe
99
+ // single token — never a '/'-joined path, which would break every `:id` route and fetch that treats an id
100
+ // as one path segment. So the disambiguation separator is '_': like '/' it never occurs inside a dir
101
+ // basename (so the join stays unambiguous), but unlike '/' it is a URL/wikilink/DOM-safe unreserved char,
102
+ // so a collision-qualified id (e.g. `.plugins_<id>`) stays a single token everywhere it is resolved.
103
+ // Exported as the ONE mint every id producer shares: spec-eval mints its node ids through this same
104
+ // function over this same universe (every spec node), so a colliding leaf carries one canonical id
105
+ // system-wide instead of a second, diverging bare-leaf scheme.
106
+ export function mintIds(segs: string[][]): string[] {
107
+ // NFC pins one canonical byte form for a non-ASCII dir name (macOS hands out NFD basenames), so a typed
108
+ // `[[中文节点]]` (NFC, what an IME emits) string-matches the minted id on every platform.
109
+ const suffix = (s: string[], k: number) => s.slice(s.length - k).join('_').normalize('NFC')
110
+ return segs.map((s, i) => {
111
+ let k = 1
112
+ while (k < s.length && segs.some((o, j) => j !== i && o.length >= k && suffix(o, k) === suffix(s, k))) k++
113
+ return suffix(s, k)
114
+ })
115
+ }
116
+
117
+ // re-key each node via the mint (overrides walk's placeholder basename id/parent); the second loop
118
+ // recomputes parent by path-ancestry.
119
+ function reId(acc: Raw[]): void {
120
+ const segs = acc.map((r) => r.relPath.split(/[/\\]/).slice(1, -1)) // path under .spec, minus 'spec.md'
121
+ const ids = mintIds(segs)
122
+ for (let i = 0; i < acc.length; i++) acc[i].id = ids[i]
123
+ for (let i = 0; i < acc.length; i++) {
124
+ let best = -1
125
+ for (let j = 0; j < acc.length; j++) {
126
+ const o = segs[j], s = segs[i]
127
+ if (j !== i && o.length < s.length && o.every((seg, x) => seg === s[x]) && (best < 0 || o.length > segs[best].length)) best = j
128
+ }
129
+ acc[i].parent = best >= 0 ? acc[best].id : null
130
+ }
131
+ }
132
+
133
+ function raws(): Raw[] {
134
+ const acc: Raw[] = []
135
+ if (existsSync(SPEC_DIR)) walk(SPEC_DIR, null, acc)
136
+ reId(acc)
137
+ return acc
138
+ }
139
+
140
+ // async twin of walk/raws for the HOT board build ([[graph-cache]]): reading each spec.md through
141
+ // fs/promises YIELDS the event loop between files, so a build never stalls a `/health` liveness probe the
142
+ // way the sync walk (one ~450ms uninterrupted stretch) did. Same output as raws() — identical push order
143
+ // (pre-order DFS, dir before children) and the same reId — so every caller reads the same nodes; only
144
+ // loadSpecs (already async, on the hot path) uses it, the light one-shot callers keep the sync raws().
145
+ async function walkAsync(dir: string, parent: string | null, acc: Raw[], root: string): Promise<void> {
146
+ let myId = parent
147
+ if (existsSync(join(dir, 'spec.md'))) {
148
+ myId = basename(dir)
149
+ const relPath = relative(root, join(dir, 'spec.md'))
150
+ const { fm, body } = parseFrontmatter(await readFile(join(dir, 'spec.md'), 'utf8'))
151
+ acc.push({ id: myId, parent, relPath, fm, body })
152
+ }
153
+ for (const e of await readdir(dir, { withFileTypes: true })) {
154
+ if (e.isDirectory()) await walkAsync(join(dir, e.name), myId, acc, root)
155
+ }
156
+ }
157
+ export type SpecTreeSnapshot = { tip: string; files: ReadonlyMap<string, string> }
158
+
159
+ async function rawsAsync(root: string, tip = 'HEAD', snapshot?: SpecTreeSnapshot): Promise<Raw[]> {
160
+ if (snapshot || tip !== 'HEAD') {
161
+ const acc: Raw[] = []
162
+ const files = snapshot?.files ?? treeTextFiles(root, tip, '.spec')
163
+ for (const [relPath, source] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
164
+ if (!relPath.endsWith('/spec.md')) continue
165
+ const segs = relPath.split('/')
166
+ const { fm, body } = parseFrontmatter(source)
167
+ acc.push({ id: segs[segs.length - 2], parent: null, relPath, fm, body })
168
+ }
169
+ reId(acc)
170
+ return acc
171
+ }
172
+ const acc: Raw[] = []
173
+ const specDir = join(root, '.spec')
174
+ if (existsSync(specDir)) await walkAsync(specDir, null, acc, root)
175
+ reId(acc)
176
+ return acc
177
+ }
178
+
179
+ // the claim rule shared by both relations (exact path, dir-prefix, or *-glob). See [[governed-related]].
180
+ function claimMatcher(file: string): (cf: string) => boolean {
181
+ const rel = file.startsWith('/') ? relative(ROOT, file) : file
182
+ return (claim) => relationClaimsPath(claim, rel)
183
+ }
184
+
185
+ // spec node(s) that GOVERN a file (frontmatter `code:` — source of truth, drives drift + eval freshness); reads only
186
+ // frontmatter (cheap, no git) so a per-edit hook can call it. `scoped` = every claiming entry carries a
187
+ // `#selector` — such a governor still displays, but does not count toward the owners bound ([[code-anchor]]).
188
+ export function specOwners(file: string): { id: string; desc: string; scoped: boolean }[] {
189
+ const claims = claimMatcher(file)
190
+ return raws().flatMap((r) => {
191
+ const entries = list(r.fm.code).map(parseCodeEntry).filter((e) => claims(e.path))
192
+ return entries.length ? [{ id: r.id, desc: str(r.fm.desc), scoped: entries.every((e) => e.anchor !== null) }] : []
193
+ })
194
+ }
195
+
196
+ // spec node(s) that REFERENCE a file (frontmatter `related:` — carries coverage, never drift, never eval freshness):
197
+ // [[governed-related]]'s other half, same claim rule, same cheap frontmatter-only read.
198
+ export function specRelated(file: string): { id: string; desc: string }[] {
199
+ const claims = claimMatcher(file)
200
+ return raws().filter((r) => list(r.fm.related).some((e) => claims(parseCodeEntry(e).path))).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
201
+ }
202
+
203
+ // memo fileDiffAt by (version sha + spec.md path) — a commit's patch is immutable. Keyed by path too: one
204
+ // commit can patch several nodes' spec.md. `{hash:'',patch:''}` for an unversioned node (no git call).
205
+ const diffCache = new Map<string, { hash: string; patch: string }>()
206
+ async function latestDiff(relPath: string, hash: string): Promise<{ hash: string; patch: string }> {
207
+ if (!hash) return { hash: '', patch: '' }
208
+ const key = `${hash}\0${relPath}`
209
+ const hit = diffCache.get(key)
210
+ if (hit) return hit
211
+ const val = { hash, patch: await fileDiffAt(ROOT, relPath, hash) }
212
+ diffCache.set(key, val)
213
+ return val
214
+ }
215
+
216
+ // filesystem-only slice of a node (id/title/path/desc/body, no git) for hot lexical reads like
217
+ // [[spec-search]]; same fields loadSpecs reports, without the git history/drift walk.
218
+ export type SpecLite = { id: string; title: string; path: string; desc: string; body: string }
219
+ export function loadSpecsLite(): SpecLite[] {
220
+ return raws().map((r) => ({
221
+ id: r.id,
222
+ title: str(r.fm.title, r.id),
223
+ path: r.relPath,
224
+ desc: str(r.fm.desc),
225
+ body: r.body.trim(),
226
+ }))
227
+ }
228
+
229
+ // one node's body + parsed parts, filesystem-only (no git). The board omits both to stay lean
230
+ // ([[graph-lean]]); the detail view fetches them here when a node opens. null when the id isn't a node.
231
+ export function specContent(id: string): { body: string; parts: ReturnType<typeof parseParts> } | null {
232
+ const r = raws().find((x) => x.id === id)
233
+ return r ? { body: r.body.trim(), parts: parseParts(r.body) } : null
234
+ }
235
+
236
+ // `root` defaults to the backend's own checkout — the canonical tree. A session worktree may be passed
237
+ // instead ([[source-of-truth]]'s several-checkouts principle at the loader level): its .spec is the
238
+ // branch's pending proposal, so eval surfaces rooted at a session must load the spec tree from the SAME
239
+ // root as their readings/indexes, or a branch-NEW node simply does not exist for them.
240
+ export type LoadSpecsOptions = {
241
+ tip?: string
242
+ history?: HistoryIndex | null
243
+ drift?: DriftIndex | null
244
+ snapshot?: SpecTreeSnapshot
245
+ }
246
+ export async function loadSpecs(root: string = ROOT, options: LoadSpecsOptions = {}) {
247
+ // The default pair shares one immutable-event snapshot; explicit sides let callers skip or supply either
248
+ // projection. Every node below is then a pure in-memory lookup.
249
+ const tip = options.tip ?? 'HEAD'
250
+ if (options.snapshot && options.snapshot.tip !== tip) {
251
+ throw new Error(`loadSpecs snapshot tip '${options.snapshot.tip}' does not match requested tip '${tip}'`)
252
+ }
253
+ const indexes = options.history === undefined && options.drift === undefined
254
+ ? sourceIndexes(root, tip)
255
+ : Promise.all([
256
+ options.history === null ? Promise.resolve(null) : options.history ?? historyIndex(root, tip),
257
+ options.drift === null ? Promise.resolve(null) : options.drift ?? driftIndex(root, tip),
258
+ ])
259
+ const [[idx, didx], allRaws] = await Promise.all([indexes, rawsAsync(root, tip, options.snapshot)])
260
+ const prepared = allRaws.map((r) => ({
261
+ r,
262
+ h: idx ? rowsFor(idx, r.relPath) : [],
263
+ codeRel: parseRelation(list(r.fm.code), 'code'),
264
+ relatedRel: parseRelation(list(r.fm.related), 'related'),
265
+ }))
266
+ if (didx) {
267
+ const queries: { hash: string; node: string }[] = []
268
+ for (const { r, h, codeRel, relatedRel } of prepared) {
269
+ if (!h[0]?.hash || (!codeRel.entries.length && !relatedRel.entries.some((entry) => !entry.selectors.length))) continue
270
+ queries.push({ hash: h[0].hash, node: r.id })
271
+ }
272
+ primeAncestorClosures(didx, queries.map(({ hash }) => hash))
273
+ // Only an ack named for this node and outside its version's ancestry becomes a cover. Discover that
274
+ // exact roster from the now-primed bases instead of retaining closures for older, non-covering acks.
275
+ const covers: string[] = []
276
+ for (const [hash, nodes] of didx.acks) if (queries.some(({ hash: baseHash, node }) => {
277
+ const base = ancestorsOf(didx, baseHash)
278
+ return !!base && nodes.has(node) && !inAncestors(didx, base, hash)
279
+ })) covers.push(hash)
280
+ primeAncestorClosures(didx, covers)
281
+ }
282
+ const loaded = []
283
+ for (const { r, h, codeRel, relatedRel } of prepared) {
284
+ // session = the Session: trailer of the node's latest version; frontmatter `session:` is the fallback.
285
+ const fmSession = str(r.fm.session)
286
+ const session = h[0]?.session || (fmSession && fmSession !== 'null' ? fmSession : null)
287
+ // a code:/related: row may pin symbols (`path#fn` — [[code-anchor]]): parseRelation groups each
288
+ // relation per BASE path, so `code`/`related` carry the distinct PATHS (what every path consumer —
289
+ // drift, claims, eval attribution — expects, file-level as before), the scoped entries (path +
290
+ // selectors) ride separately for lint's anchor engine, and structural problems (duplicates,
291
+ // bare/scoped mixing, glob selectors, the code cap) surface as lint integrity errors.
292
+ const codeEntries = codeRel.entries
293
+ const code = codeEntries.map((e) => e.path)
294
+ const codeScoped = codeEntries.filter((e) => e.selectors.length > 0)
295
+ const relatedEntries = relatedRel.entries
296
+ const related = relatedEntries.map((e) => e.path)
297
+ const relatedScoped = relatedEntries.filter((e) => e.selectors.length > 0)
298
+ const relationProblems = [...codeRel.problems, ...relatedRel.problems]
299
+ const S = h[0]?.hash || ''
300
+ const driftFiles = []
301
+ for (const f of code) {
302
+ const d = didx ? { file: f, behind: driftFor(didx, S, f, r.id) } : { file: f, behind: 0 }
303
+ if (d.behind > 0) driftFiles.push(d)
304
+ }
305
+ const drift = driftFiles.reduce((a, d) => a + d.behind, 0)
306
+ // related drift is the SOFT tier ([[governed-related]]): same ancestry basis, but it stays OUT of
307
+ // `drift` — it never feeds status, the commit gate, or eval freshness. It surfaces only as a lint warn nudge.
308
+ // A SCOPED related entry is excluded here: its file-level movement is silent by design — only a
309
+ // selector HIT warns, and that verdict needs the anchor engine, so lint derives it, not the loader.
310
+ const relatedDriftFiles = []
311
+ for (const e of relatedEntries) {
312
+ if (e.selectors.length) continue
313
+ const d = didx ? { file: e.path, behind: driftFor(didx, S, e.path, r.id) } : { file: e.path, behind: 0 }
314
+ if (d.behind > 0) relatedDriftFiles.push(d)
315
+ }
316
+ const fmStatus = str(r.fm.status, '') || null
317
+ loaded.push({
318
+ id: r.id,
319
+ parent: r.parent,
320
+ path: r.relPath,
321
+ title: str(r.fm.title, r.id),
322
+ status: deriveStatus({ version: h.length, drift, hasCode: code.length > 0, fmStatus: fmStatus ?? undefined }),
323
+ fmStatus,
324
+ session,
325
+ hue: Number(str(r.fm.hue, '210')),
326
+ desc: str(r.fm.desc),
327
+ code,
328
+ codeEntries,
329
+ codeScoped,
330
+ related,
331
+ relatedEntries,
332
+ relatedScoped,
333
+ relationProblems,
334
+ version: h.length,
335
+ reason: h[0]?.reason || '',
336
+ // ISO date of the node's latest version commit (h is newest-first), or null if unversioned.
337
+ lastEdited: h[0]?.date || null,
338
+ drift,
339
+ driftFiles,
340
+ relatedDriftFiles,
341
+ // the latest version's spec.md patch is NOT precomputed here (it cost 2 git show forks per node on
342
+ // cold load); the history tab fetches it lazily via specDiffAt. See [[work-pane]].
343
+ body: r.body.trim(),
344
+ parts: parseParts(r.body),
345
+ })
346
+ }
347
+ return loaded
348
+ }
349
+
350
+ // per-node version timeline; each row sums the node's spec.md stat (rename-followed, read on demand) and its
351
+ // governed-code stat (pathsStats) — separate because spec.md needs rename-following a plain `git log -- path` can't do.
352
+ export async function specHistory(id: string) {
353
+ const node = raws().find((r) => r.id === id)
354
+ if (!node) return []
355
+ const codePaths = [...new Set(list(node.fm.code).map((e) => parseCodeEntry(e).path))]
356
+ // index (cached) and the code-path walk are independent — run them in parallel, both async git.
357
+ const [idx, cStats] = await Promise.all([historyIndex(ROOT), pathsStats(ROOT, codePaths)])
358
+ const sStats = await historyStats(ROOT, idx, node.relPath)
359
+ return rowsFor(idx, node.relPath).map((v) => {
360
+ const s = sStats.get(v.hash) ?? { additions: 0, deletions: 0, files: 0 }
361
+ const c = cStats.get(v.hash) ?? { additions: 0, deletions: 0, files: 0 }
362
+ return { ...v, additions: s.additions + c.additions, deletions: s.deletions + c.deletions, files: s.files + c.files }
363
+ })
364
+ }
365
+
366
+ // the line-diff a specific version introduced to a node's spec.md, by hash; fetched lazily when a history
367
+ // item expands. fileDiffAt resolves the spec.md path AT that commit (reparents). `{hash:'',patch:''}` for
368
+ // an empty hash, null for an unknown id.
369
+ export async function specDiffAt(id: string, hash: string) {
370
+ const node = raws().find((r) => r.id === id)
371
+ if (!node) return null
372
+ if (!hash) return { hash: '', patch: '' }
373
+ return latestDiff(node.relPath, hash)
374
+ }
375
+
376
+ // plugin presets - REFLEXIVE, SKILL-SHAPED preset nodes whose folder IS a skill bundle: `spec.md`'s
377
+ // body is the agent prompt/contract (with a {{targets}} placeholder the launcher fills with the
378
+ // @-referenced nodes), and the SAME folder may co-locate auxiliary files — scripts, assets — that the
379
+ // preset ships for the agent to run deterministically. So each preset reports its folder `dir`
380
+ // (repo-relative) and its `files` (co-located paths, spec.md excluded) alongside name/title/desc/kind/body.
381
+ // `kind` ∈ mutating|report tells the launcher whether the preset edits the graph or only reports on it.
382
+ // `events`/`order`/`block` are populated only for the `hook` surface (empty/0/false otherwise): which
383
+ // harness lifecycle events the node binds, its deterministic intra-event order, and whether it intends to
384
+ // block (honored only on block-capable events). See loadHookConfig + the hook compiler/dispatcher.
385
+ export type ConfigPreset = { name: string; title: string; desc: string; kind: string; dir: string; files: string[]; body: string; events: string[]; order: number; block: boolean; tools: string[] }
386
+ // field-driven surface - a plugin is a spec node at ANY depth under a plugin root that carries a
387
+ // `surface: system|command|hook|skill|agent|review` frontmatter field naming where it plugs in. There are no
388
+ // `command/`/`system/`/`hook/`/`skill/`/`agent/` bucket dirs (those were graph-invisible grouping dirs with no spec.md, so
389
+ // the spec graph skipped them — path != graph); the surface is a FIELD on the node, so the plugin is a real
390
+ // graph child (a grouping parent like `.plugins/prompts` is itself a spec node, never a bare dir). BOTH plugin roots participate: `.plugins` (the instance — DIY dev-flow plugins) and
391
+ // `plugin-system` (the project system spec). loadConfig gathers the `command` surface, loadSystemConfig the `system`
392
+ // surface, loadHookConfig the `hook` surface, loadSkillConfig the `skill` surface, loadAgentConfig the `agent`
393
+ // surface (sub-agent definitions); each scans the children under every root and filters by the field. The plugins also show on the board as ordinary spec nodes (via loadSpecs).
394
+ // root node - the spec tree's single top-level node: the one directory directly under .spec/ that
395
+ // holds a spec.md. The dogfood repo names it 'spexcode'; a repo scaffolded by `spex init` names it
396
+ // 'project' (or whatever the adopter renames it to). Detected DYNAMICALLY so the config loaders resolve
397
+ // the ACTUAL root's config dirs — never a hardcoded 'spexcode', which silently returned [] in an adopter
398
+ // repo, so their .plugins/core contract never loaded and their launched agents got no system prompt.
399
+ // Returns null when .spec holds no such directory. (resolveLayout's `main` is a checkout PATH, not the
400
+ // root node NAME, so it can't serve this — a tiny filesystem probe is the right seam.)
401
+ function rootNode(): string | null {
402
+ if (!existsSync(SPEC_DIR)) return null
403
+ for (const e of readdirSync(SPEC_DIR, { withFileTypes: true })) {
404
+ if (e.isDirectory() && existsSync(join(SPEC_DIR, e.name, 'spec.md'))) return e.name
405
+ }
406
+ return null
407
+ }
408
+ // resolved at call time (not module-eval) so it tracks the live tree.
409
+ // @@@ legacy-tree refusal - v0.3.0 renamed the plugin instance root `.config` → `.plugins`. A pre-0.3.0
410
+ // tree would otherwise load an EMPTY plugin surface — no contract block, no hooks, no commands — and the
411
+ // launched agents would silently run ungoverned. So refuse loudly: existence-only probe (never a dual
412
+ // read of legacy content), pointing at the one-shot migrator. Delete this check in 0.4.0.
413
+ function configRoots(): string[] {
414
+ const root = rootNode()
415
+ if (!root) return []
416
+ if (existsSync(join(SPEC_DIR, root, '.config')) && !existsSync(join(SPEC_DIR, root, '.plugins'))) {
417
+ throw new Error(
418
+ `.spec/${root}/.config exists but .spec/${root}/.plugins does not — this spec tree predates the v0.3.0 ` +
419
+ `plugin rename (.config → .plugins). Refusing to load an empty plugin surface (agents would launch ` +
420
+ `ungoverned). Run \`spex doctor --migrate\` to migrate the tree.`)
421
+ }
422
+ return ['.plugins', 'plugin-system'].map((r) => join(SPEC_DIR, root, r))
423
+ }
424
+ // co-located bundle files = everything under the node folder except its spec.md, repo-relative, recursive.
425
+ function bundleFiles(dir: string): string[] {
426
+ const out: string[] = []
427
+ const walk = (d: string) => {
428
+ for (const e of readdirSync(d, { withFileTypes: true })) {
429
+ const p = join(d, e.name)
430
+ if (e.isDirectory()) walk(p)
431
+ else if (e.name !== 'spec.md') out.push(relative(ROOT, p))
432
+ }
433
+ }
434
+ walk(dir)
435
+ return out.sort()
436
+ }
437
+ // gather the preset nodes under a plugin root that declare `surface: <surface>`. The scan is RECURSIVE —
438
+ // `surface` is a FIELD, not a path (the design's core tenet), so a plugin may live at ANY depth: under a
439
+ // surface-less grouping shelf (the auxiliary `surface: system` contracts live under `.plugins/prompts/`),
440
+ // or under a plugin that is itself a grouping parent (`.plugins/core` is a `surface: system` contract whose
441
+ // CHILDREN are `surface: hook` nodes). The field filter keeps it safe: a node only gathers if it declares THIS
442
+ // surface, so descending past a matched node never double-counts (children carry a different surface),
443
+ // and the gather set is path-independent — regrouping a plugin never changes what materializes.
444
+ function loadSurface(surface: 'command' | 'system' | 'hook' | 'skill' | 'agent' | 'review'): ConfigPreset[] {
445
+ const out: ConfigPreset[] = []
446
+ const visit = (nodeDir: string, name: string) => {
447
+ if (existsSync(join(nodeDir, 'spec.md'))) {
448
+ const { fm, body } = parseFrontmatter(readFileSync(join(nodeDir, 'spec.md'), 'utf8'))
449
+ // @@@ skip pending - a `status: pending` plugin is DECLARED INTENT, not yet active. It renders on the
450
+ // board (via loadSpecs) but must NOT gather: neither a command preset, nor folded into a system prompt,
451
+ // nor a live hook. Only built/active plugins surface here, so pending stubs stay inert.
452
+ // the surface field may name SEVERAL surfaces (comma-separated or a YAML list) — the node plugs
453
+ // into every one it lists, so the match is membership, not equality.
454
+ const surfaces = list(fm.surface).flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean)
455
+ if (surfaces.includes(surface) && str(fm.status) !== 'pending') {
456
+ out.push({
457
+ name,
458
+ title: str(fm.title, name),
459
+ desc: str(fm.desc),
460
+ kind: str(fm.kind, 'mutating'),
461
+ dir: relative(ROOT, nodeDir),
462
+ files: bundleFiles(nodeDir),
463
+ body: body.trim(),
464
+ events: list(fm.events),
465
+ order: Number(str(fm.order, '0')) || 0,
466
+ block: str(fm.block) === 'true',
467
+ tools: list(fm.tools),
468
+ })
469
+ }
470
+ }
471
+ for (const e of readdirSync(nodeDir, { withFileTypes: true })) {
472
+ if (e.isDirectory()) visit(join(nodeDir, e.name), e.name)
473
+ }
474
+ }
475
+ for (const root of configRoots()) {
476
+ if (!existsSync(root)) continue
477
+ for (const e of readdirSync(root, { withFileTypes: true })) {
478
+ if (e.isDirectory()) visit(join(root, e.name), e.name)
479
+ }
480
+ }
481
+ return out.sort((a, b) => a.name.localeCompare(b.name))
482
+ }
483
+ export function loadConfig(): ConfigPreset[] { return loadSurface('command') }
484
+ export function loadSystemConfig(): ConfigPreset[] { return loadSurface('system') }
485
+ // the hook handlers (compiled into the per-session hook manifest the dispatcher reads). Each carries its
486
+ // `events`/`order`/`block` binding + co-located script `files`.
487
+ export function loadHookConfig(): ConfigPreset[] { return loadSurface('hook') }
488
+ // the skill bundles (materialized into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
489
+ // load-trigger and its `body` is the on-demand instructions; loadSurface passes the folder basename as `name`.
490
+ export function loadSkillConfig(): ConfigPreset[] { return loadSurface('skill') }
491
+ // the sub-agent definitions (materialized into each harness's auto-discovered agent dir, e.g. claude's
492
+ // .claude/agents/<name>.md). Like a skill, the node's `desc` is the on-demand load-trigger and its `body` is the
493
+ // agent's system prompt; additionally its `tools` field is the harness tool allowlist for the spawned agent.
494
+ export function loadAgentConfig(): ConfigPreset[] { return loadSurface('agent') }
495
+ // the review-track prose presets ([[review-commands]]): offered in the eval detail's remark-composer `/`
496
+ // dropdown; picking one PREFILLS the composer with the node's `body` ({node}/{scenario}/{expected}
497
+ // placeholders filled at insert time). Display+prefill only — the send stays the ordinary remark write.
498
+ export function loadReviewConfig(): ConfigPreset[] { return loadSurface('review') }
@@ -0,0 +1,33 @@
1
+ {
2
+ "lint": {
3
+ "governedRoots": ["."]
4
+ },
5
+ "dashboard": {
6
+ "showHeadlessLaunchers": false
7
+ },
8
+ "uploads": {
9
+ "maxBytes": 2147483648,
10
+ "chunkBytes": 8388608,
11
+ "concurrency": 1,
12
+ "requestTimeoutMs": 120000,
13
+ "retryLimit": 2,
14
+ "retryDelayMs": 500,
15
+ "incompleteTtlMs": 86400000,
16
+ "cleanupIntervalMs": 3600000,
17
+ "minFreeBytes": 268435456,
18
+ "evidenceMaxBytes": 52428800
19
+ },
20
+ "sessions": {
21
+ "launchers": {
22
+ "claude": { "harness": "claude", "cmd": "claude" },
23
+ "claude-headless": { "harness": "claude-headless", "cmd": "claude" },
24
+ "codex": { "harness": "codex", "cmd": "codex" },
25
+ "codex-headless": { "harness": "codex-headless", "cmd": "codex --yolo" },
26
+ "opencode": { "harness": "opencode", "cmd": "opencode" },
27
+ "opencode-headless": { "harness": "opencode-headless", "cmd": "opencode --auto" },
28
+ "pi": { "harness": "pi", "cmd": "pi" },
29
+ "pi-headless": { "harness": "pi-headless", "cmd": "pi" }
30
+ },
31
+ "defaultLauncher": "claude"
32
+ }
33
+ }