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/git.ts ADDED
@@ -0,0 +1,492 @@
1
+ import { execFileSync, execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
4
+ import { join, isAbsolute, resolve } from 'node:path'
5
+
6
+ const US = '\x1f', RS = '\x1e'
7
+
8
+ // strip git's hook-exported env (GIT_DIR etc.) so every call discovers the repo from the filesystem.
9
+ export function git(args: string[]): string {
10
+ const env = { ...process.env }
11
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
12
+ return execFileSync('git', args, { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] })
13
+ }
14
+
15
+ const pexecFile = promisify(execFile)
16
+ export async function gitA(args: string[]): Promise<string> {
17
+ const env = { ...process.env }
18
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
19
+ try {
20
+ const { stdout } = await pexecFile('git', args, { encoding: 'utf8', env, maxBuffer: 1 << 24 })
21
+ return stdout
22
+ } catch { return '' }
23
+ }
24
+
25
+ export async function gitTry(args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
26
+ const env = { ...process.env }
27
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
28
+ try {
29
+ const { stdout, stderr } = await pexecFile('git', args, { encoding: 'utf8', env, maxBuffer: 1 << 24 })
30
+ return { ok: true, stdout, stderr }
31
+ } catch (e: any) {
32
+ return { ok: false, stdout: e?.stdout ?? '', stderr: e?.stderr ?? String(e?.message ?? e) }
33
+ }
34
+ }
35
+
36
+ // memoized: repoRoot is constant per process, but resolveLayout() calls it per request — avoid a git fork each time.
37
+ let repoRootCache: string | null = null
38
+ export function repoRoot(): string {
39
+ if (repoRootCache !== null) return repoRootCache
40
+ try {
41
+ repoRootCache = git(['rev-parse', '--show-toplevel']).trim()
42
+ } catch {
43
+ repoRootCache = process.cwd()
44
+ }
45
+ return repoRootCache
46
+ }
47
+
48
+ function gitDirOf(root: string): string {
49
+ // a normal checkout has a `.git` DIRECTORY; a linked worktree has a `.git` FILE: `gitdir: <path>`.
50
+ const dotgit = join(root, '.git')
51
+ if (statSync(dotgit).isDirectory()) return dotgit
52
+ const m = readFileSync(dotgit, 'utf8').match(/^gitdir:\s*(.+)$/m)
53
+ if (!m) throw new Error(`headSha: unparseable .git file at ${dotgit}`)
54
+ const dir = m[1].trim()
55
+ return isAbsolute(dir) ? dir : resolve(root, dir)
56
+ }
57
+ function commonDirOf(gitDir: string): string {
58
+ // a worktree's gitdir holds per-worktree state (HEAD); SHARED refs (refs/heads/*, packed-refs) live
59
+ // in the common dir, named by the `commondir` pointer. A plain checkout IS its own common dir.
60
+ const p = join(gitDir, 'commondir')
61
+ if (!existsSync(p)) return gitDir
62
+ const c = readFileSync(p, 'utf8').trim()
63
+ return isAbsolute(c) ? c : resolve(gitDir, c)
64
+ }
65
+ export function headSha(root: string): string {
66
+ const gitDir = gitDirOf(root)
67
+ const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim()
68
+ const ref = head.match(/^ref:\s*(.+)$/)
69
+ if (!ref) return head // detached HEAD: the file already holds the sha
70
+ const name = ref[1].trim()
71
+ // a loose ref wins over packed; per-worktree HEAD points at a branch whose ref lives in the common dir.
72
+ const looseWt = join(gitDir, name)
73
+ if (existsSync(looseWt)) return readFileSync(looseWt, 'utf8').trim()
74
+ const common = commonDirOf(gitDir)
75
+ const loose = join(common, name)
76
+ if (existsSync(loose)) return readFileSync(loose, 'utf8').trim()
77
+ const packed = join(common, 'packed-refs')
78
+ if (existsSync(packed)) {
79
+ for (const line of readFileSync(packed, 'utf8').split('\n')) {
80
+ if (!line || line[0] === '#' || line[0] === '^') continue
81
+ const sp = line.indexOf(' ')
82
+ if (sp > 0 && line.slice(sp + 1).trim() === name) return line.slice(0, sp).trim()
83
+ }
84
+ }
85
+ // an UNBORN HEAD — a fresh `git init` with no commits — points at a branch ref that doesn't exist yet.
86
+ // That is a valid EMPTY-HISTORY state, not a failure: the board renders fine from the working tree. Return
87
+ // a stable, truthy sentinel so historyIndex/driftIndex/safeHead MEMOIZE it (the head value is only ever a
88
+ // cache key, never a git ref) instead of re-forking git on every read; headOrEmpty's warning is then
89
+ // reserved for a genuinely unreadable HEAD and never fires for this routine first-run state.
90
+ return `unborn:${name}`
91
+ }
92
+
93
+ // fingerprint of a worktree's `.spec` working tree by path + mtimeMs + size (no git); the overlay-cache
94
+ // key for its working-tree state. '' when `.spec` is absent.
95
+ export function worktreeSpecSig(wtPath: string): string {
96
+ const root = join(wtPath, '.spec')
97
+ if (!existsSync(root)) return ''
98
+ const parts: string[] = []
99
+ const stack = [root]
100
+ while (stack.length) {
101
+ const dir = stack.pop()!
102
+ let ents
103
+ try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
104
+ for (const e of ents) {
105
+ const p = join(dir, e.name)
106
+ if (e.isDirectory()) { stack.push(p); continue }
107
+ try { const st = statSync(p); parts.push(`${p}:${st.mtimeMs}:${st.size}`) } catch { /* vanished mid-walk */ }
108
+ }
109
+ }
110
+ return parts.sort().join('\n')
111
+ }
112
+
113
+ export type Version = { hash: string; date: string; reason: string; session: string | null }
114
+ export type DiffStat = { additions: number; deletions: number; files: number }
115
+
116
+ // per-commit numstat for one file, rename-followed; a pure rename is 0/0 so callers tell "moved" from
117
+ // "changed". The per-file path for files outside `.spec` (`.spec` goes through the bulk index below).
118
+ function fileStatsFollow(root: string, relPath: string): Map<string, DiffStat> {
119
+ const m = new Map<string, DiffStat>()
120
+ let out = ''
121
+ try {
122
+ out = git(['-C', root, 'log', '--follow', '--format=%H', '--numstat', '--', relPath])
123
+ } catch {
124
+ return m
125
+ }
126
+ let cur = ''
127
+ for (const line of out.split('\n')) {
128
+ const t = line.trim()
129
+ if (/^[0-9a-f]{7,40}$/.test(t)) { cur = t; if (!m.has(cur)) m.set(cur, { additions: 0, deletions: 0, files: 0 }); continue }
130
+ const n = line.match(/^(\d+|-)\t(\d+|-)\t/)
131
+ if (n && cur) { const s = m.get(cur)!; s.files++; s.additions += n[1] === '-' ? 0 : +n[1]; s.deletions += n[2] === '-' ? 0 : +n[2] }
132
+ }
133
+ return m
134
+ }
135
+
136
+ function historyFollow(root: string, relPath: string): Version[] {
137
+ let out = ''
138
+ try {
139
+ out = git(['-C', root, 'log', `--format=%H${US}%aI${US}%s${US}%b${RS}`, '--follow', '--', relPath])
140
+ } catch {
141
+ return []
142
+ }
143
+ const stats = fileStatsFollow(root, relPath)
144
+ return out.split(RS).map((r) => r.trim()).filter(Boolean).map((rec) => {
145
+ const [hash, date, reason, body = ''] = rec.split(US)
146
+ const m = body.match(/Session:\s*(\S+)/)
147
+ return { hash, date, reason, session: m ? m[1] : null }
148
+ }).filter((v) => { const s = stats.get(v.hash); return s != null && s.additions + s.deletions > 0 })
149
+ }
150
+
151
+ // ---- bulk spec history index ----
152
+
153
+ export type HistoryIndex = {
154
+ versions: Map<string, Version[]> // headPath -> rows newest-first (incl. pure-rename rows)
155
+ stats: Map<string, Map<string, DiffStat>> // headPath -> (commit hash -> this file's diffstat there)
156
+ }
157
+
158
+ // git numstat encodes a rename as `dir/{old => new}/file` (either side may be empty) or `old => new`;
159
+ // recover both endpoints. Spec paths are brace/space-free here, so the textual parse is unambiguous.
160
+ function parseStatPath(token: string): { from: string; to: string } {
161
+ const b = token.indexOf('{')
162
+ if (b >= 0) {
163
+ const arrow = token.indexOf(' => ', b)
164
+ const close = token.indexOf('}', arrow)
165
+ if (arrow > b && close > arrow) {
166
+ const pre = token.slice(0, b), post = token.slice(close + 1)
167
+ const from = (pre + token.slice(b + 1, arrow) + post).replace(/\/\//g, '/')
168
+ const to = (pre + token.slice(arrow + 4, close) + post).replace(/\/\//g, '/')
169
+ return { from, to }
170
+ }
171
+ }
172
+ const i = token.indexOf(' => ')
173
+ if (i >= 0) return { from: token.slice(0, i), to: token.slice(i + 4) }
174
+ return { from: token, to: token }
175
+ }
176
+
177
+ let indexCache: { head: string; idx: HistoryIndex } | null = null
178
+
179
+ export async function historyIndex(root: string): Promise<HistoryIndex> {
180
+ const head = headOrEmpty(root)
181
+ if (indexCache && head && indexCache.head === head) return indexCache.idx
182
+ const idx = await buildIndex(root)
183
+ if (head) indexCache = { head, idx }
184
+ return idx
185
+ }
186
+
187
+ // resolve HEAD for cache-keying, '' if unreadable (fails the cache test → recompute); warns once.
188
+ let headWarned = false
189
+ function headOrEmpty(root: string): string {
190
+ try { return headSha(root) }
191
+ catch (e) {
192
+ if (!headWarned) { headWarned = true; console.warn(`spec-cli: headSha failed, recomputing every read: ${(e as Error).message}`) }
193
+ return ''
194
+ }
195
+ }
196
+
197
+ async function buildIndex(root: string): Promise<HistoryIndex> {
198
+ const versions = new Map<string, Version[]>()
199
+ const stats = new Map<string, Map<string, DiffStat>>()
200
+ const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '-M', '--numstat',
201
+ `--format=${RS}%H${US}%aI${US}%s${US}%b`, '--', '.spec'])
202
+ if (!out) return { versions, stats }
203
+ // Walk newest -> oldest (git log default). `alias` maps a path as it exists at the current walk
204
+ // point to its head (current) path; the first (newest) time we meet a file, that path IS its head.
205
+ const alias = new Map<string, string>()
206
+ for (const rec of out.split(RS)) {
207
+ const r = rec.replace(/^\n/, '')
208
+ if (!r) continue
209
+ const parts = r.split(US)
210
+ const hash = parts[0], date = parts[1], reason = parts[2]
211
+ const rest = parts.slice(3).join(US) // body (had no US) followed by the numstat block
212
+ const sm = rest.match(/Session:\s*(\S+)/)
213
+ const version: Version = { hash, date, reason, session: sm ? sm[1] : null }
214
+ for (const line of rest.split('\n')) {
215
+ const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
216
+ if (!m) continue
217
+ const add = m[1] === '-' ? 0 : +m[1]
218
+ const del = m[2] === '-' ? 0 : +m[2]
219
+ const { from, to } = parseStatPath(m[3])
220
+ let head = alias.get(to)
221
+ if (head === undefined) { head = to; alias.set(to, to) }
222
+ if (!versions.has(head)) versions.set(head, [])
223
+ versions.get(head)!.push(version)
224
+ let hs = stats.get(head)
225
+ if (!hs) { hs = new Map(); stats.set(head, hs) }
226
+ const s = hs.get(hash) ?? { additions: 0, deletions: 0, files: 0 }
227
+ s.additions += add; s.deletions += del; s.files += 1
228
+ hs.set(hash, s)
229
+ if (from !== to) { alias.set(from, head); alias.delete(to) } // older history calls it `from`
230
+ }
231
+ }
232
+ return { versions, stats }
233
+ }
234
+
235
+ // reset the cache when a process knows HEAD will have moved out from under it (tests, hooks).
236
+ export function resetHistoryCache(): void { indexCache = null }
237
+
238
+ // pure lookups over a prebuilt index (no git). rowsFor drops pure-rename rows (0/0) so a move isn't a version.
239
+ export function rowsFor(idx: HistoryIndex, relPath: string): Version[] {
240
+ const rows = idx.versions.get(relPath) ?? []
241
+ const st = idx.stats.get(relPath)
242
+ return rows.filter((v) => { const s = st?.get(v.hash); return s != null && s.additions + s.deletions > 0 })
243
+ }
244
+ export function statsFor(idx: HistoryIndex, relPath: string): Map<string, DiffStat> {
245
+ return idx.stats.get(relPath) ?? new Map()
246
+ }
247
+
248
+ // per-commit numstat summed over a SET of paths in one `git log` walk. No `--follow` (it takes a single
249
+ // path), so no rename-tracking — same as the old `git show -- paths`; spec.md gets renames via the bulk index.
250
+ export async function pathsStats(root: string, paths: string[]): Promise<Map<string, DiffStat>> {
251
+ const m = new Map<string, DiffStat>()
252
+ if (!paths.length) return m
253
+ const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '--format=%H', '--numstat', '--', ...paths])
254
+ if (!out) return m
255
+ let cur = ''
256
+ for (const line of out.split('\n')) {
257
+ const t = line.trim()
258
+ if (/^[0-9a-f]{7,40}$/.test(t)) { cur = t; continue }
259
+ const n = line.match(/^(\d+|-)\t(\d+|-)\t/)
260
+ if (n && cur) {
261
+ const s = m.get(cur) ?? { additions: 0, deletions: 0, files: 0 }
262
+ s.files++; s.additions += n[1] === '-' ? 0 : +n[1]; s.deletions += n[2] === '-' ? 0 : +n[2]
263
+ m.set(cur, s)
264
+ }
265
+ }
266
+ return m
267
+ }
268
+
269
+ // a file's version timeline: `.spec` from the bulk index, anything else (governed code) via per-file --follow.
270
+ export async function history(root: string, relPath: string): Promise<Version[]> {
271
+ if (relPath.startsWith('.spec/')) return rowsFor(await historyIndex(root), relPath)
272
+ return historyFollow(root, relPath)
273
+ }
274
+
275
+ // per-commit stat for this node's spec.md (rename-followed), summed with governed-code stats by specHistory.
276
+ export async function specStats(root: string, relPath: string): Promise<Map<string, DiffStat>> {
277
+ if (relPath.startsWith('.spec/')) return statsFor(await historyIndex(root), relPath)
278
+ return fileStatsFollow(root, relPath)
279
+ }
280
+
281
+ // the patch a spec.md got in one commit (vs parent); resolve its path AT that commit (reparents move it)
282
+ // via the stable leaf dir `…/<id>/spec.md`, then `git show` that path. `-M` keeps a rename+edit's body. '' on error.
283
+ export async function fileDiffAt(root: string, relPath: string, hash: string): Promise<string> {
284
+ if (!hash || !relPath.endsWith('/spec.md')) return ''
285
+ const leaf = relPath.slice(relPath.lastIndexOf('/', relPath.length - '/spec.md'.length - 1) + 1) // `<id>/spec.md`
286
+ const names = await gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '--name-only', '--format=', '-M', hash])
287
+ const at = names.split('\n').map((s) => s.trim()).find((p) => p.endsWith('/' + leaf) || p === leaf) ?? relPath
288
+ return gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '-M', '--format=', hash, '--', at])
289
+ }
290
+
291
+ // ONE cached `git log` over HEAD (mirrors historyIndex): each commit's position (0 = newest) + per
292
+ // file the commits that touched it; driftFor() is then a pure lookup. `acks`/`specNodes` carry the
293
+ // Spec-OK convention (see driftFor): acks[hash] = node ids declared still-valid via `Spec-OK:` trailers;
294
+ // specNodes[hash] = node ids whose spec.md it touched.
295
+ export type DriftIndex = {
296
+ pos: Map<string, number>
297
+ fileCommits: Map<string, string[]>
298
+ acks: Map<string, Set<string>> // commit hash -> node ids acknowledged via `Spec-OK:` trailers
299
+ specNodes: Map<string, Set<string>> // commit hash -> node ids whose spec.md it touched (its versions)
300
+ }
301
+ let driftIdxCache: { head: string; idx: DriftIndex } | null = null
302
+
303
+ async function buildDriftIndex(root: string): Promise<DriftIndex> {
304
+ const pos = new Map<string, number>(), fileCommits = new Map<string, string[]>()
305
+ const acks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
306
+ // RS-delimited records: `<hash>US<comma-joined Spec-OK values>` on line 1, then the --name-only file
307
+ // list. `valueonly,separator` collapses the trailer block to one line so it never collides with the
308
+ // file names below it (a raw `%b` body would interleave with them and be unparseable).
309
+ const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '--name-only',
310
+ `--format=${RS}%H${US}%(trailers:key=Spec-OK,valueonly,separator=%x2C)`, 'HEAD'])
311
+ if (!out) return { pos, fileCommits, acks, specNodes }
312
+ let i = 0
313
+ for (const rec of out.split(RS)) {
314
+ const r = rec.replace(/^\n/, '')
315
+ if (!r) continue
316
+ const lines = r.split('\n')
317
+ const [hash, ackStr = ''] = lines[0].split(US)
318
+ if (!hash) continue
319
+ if (!pos.has(hash)) pos.set(hash, i++)
320
+ const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean))
321
+ if (ackSet.size) acks.set(hash, ackSet)
322
+ for (const line of lines.slice(1)) {
323
+ if (!line) continue
324
+ let arr = fileCommits.get(line); if (!arr) { arr = []; fileCommits.set(line, arr) }
325
+ arr.push(hash)
326
+ if (isSpecMd(line)) {
327
+ let ns = specNodes.get(hash); if (!ns) { ns = new Set(); specNodes.set(hash, ns) }
328
+ ns.add(nodeIdOf(line))
329
+ }
330
+ }
331
+ }
332
+ return { pos, fileCommits, acks, specNodes }
333
+ }
334
+ export async function driftIndex(root: string): Promise<DriftIndex> {
335
+ const head = headOrEmpty(root) // filesystem HEAD, no subprocess — see historyIndex
336
+ if (driftIdxCache && head && driftIdxCache.head === head) return driftIdxCache.idx
337
+ const idx = await buildDriftIndex(root)
338
+ if (head) driftIdxCache = { head, idx }
339
+ return idx
340
+ }
341
+ // pure lookup: how many commits to `path` are newer than `sinceHash` (the spec's last version) and not
342
+ // yet acknowledged. No git calls.
343
+ //
344
+ // `sinceHash` is the node's OWN latest version commit, so the node(s) it's a version of
345
+ // (specNodes[sinceHash]) name the node being measured; an ack counts only if its `Spec-OK:` set names one
346
+ // of those — `Spec-OK: A` quiets A's drift, never B's.
347
+ export function driftFor(idx: DriftIndex, sinceHash: string, path: string): number {
348
+ if (!sinceHash) return 0
349
+ const sp = idx.pos.get(sinceHash)
350
+ if (sp === undefined) return 0
351
+ const targets = idx.specNodes.get(sinceHash)
352
+ // the newest commit that acks a target (smallest pos = closest to HEAD) is the floor at/below which all
353
+ // drift is acknowledged. No target / no matching ack → Infinity, so nothing is quieted. An ack at or
354
+ // before the version (p >= sp) can't speak for it — a re-version invalidates older acks.
355
+ let ackPos = Infinity
356
+ if (targets) {
357
+ for (const [h, ackSet] of idx.acks) {
358
+ const p = idx.pos.get(h)
359
+ if (p === undefined || p >= sp) continue
360
+ if ([...targets].some((t) => ackSet.has(t))) ackPos = Math.min(ackPos, p)
361
+ }
362
+ }
363
+ let n = 0
364
+ for (const h of idx.fileCommits.get(path) ?? []) {
365
+ const p = idx.pos.get(h)
366
+ if (p === undefined || p >= sp) continue // older than / at the version → not drift
367
+ if (p >= ackPos) continue // at or below the newest covering ack → acknowledged
368
+ n++
369
+ }
370
+ return n
371
+ }
372
+
373
+ // the paths git is about to commit (index vs HEAD), scoping the pre-commit drift gate to this commit's files.
374
+ export function stagedFiles(root: string): string[] {
375
+ try {
376
+ return git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--cached', '--name-only'])
377
+ .split('\n').map((s) => s.trim()).filter(Boolean)
378
+ } catch { return [] }
379
+ }
380
+
381
+ // ---- pending worktree changes (the board's runtime overlay) ----
382
+
383
+ // one pending change a worktree makes to a spec node vs main; committed = on the branch, dirty = uncommitted edits.
384
+ export type NodeOp = {
385
+ nodeId: string
386
+ op: 'added' | 'edited' | 'deleted' | 'moved'
387
+ path: string // the node's spec.md path (new path for moved/added, old for deleted)
388
+ fromPath?: string; toPath?: string // set for 'moved' (a reparent renames the spec.md path)
389
+ committed: boolean; dirty: boolean
390
+ }
391
+
392
+ // node id = the directory holding the spec.md (basename of its parent dir). git always emits
393
+ // forward-slash paths, so split rather than node:path (which is backslash-y on Windows).
394
+ const nodeIdOf = (p: string): string => { const s = p.split('/'); return s[s.length - 2] ?? p }
395
+ const isSpecMd = (p: string): boolean => p.endsWith('/spec.md')
396
+
397
+ // `git ... --name-status -M` rows: `A\tpath`, `M\tpath`, `D\tpath`, `R100\told\tnew`. Recover the
398
+ // status letter plus from/to (to === from for non-renames) so callers map letter -> op uniformly.
399
+ function parseNameStatus(out: string): { code: string; from: string; to: string }[] {
400
+ const rows: { code: string; from: string; to: string }[] = []
401
+ for (const line of out.split('\n')) {
402
+ if (!line) continue
403
+ const parts = line.split('\t')
404
+ const code = parts[0][0]
405
+ if ((code === 'R' || code === 'C') && parts.length >= 3) rows.push({ code, from: parts[1], to: parts[2] })
406
+ else rows.push({ code, from: parts[1], to: parts[1] })
407
+ }
408
+ return rows
409
+ }
410
+
411
+ export type ReviewDiffFile = { path: string; status: string; additions: number; deletions: number }
412
+ const DIFF_STATUS: Record<string, string> = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' }
413
+ export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
414
+ const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
415
+ const base = (await run(['merge-base', mainRef, 'HEAD'])).trim()
416
+ if (!base) return []
417
+ const [numstatOut, statusOut] = await Promise.all([
418
+ run(['diff', '--numstat', '-M', `${base}..HEAD`]),
419
+ run(['diff', '--name-status', '-M', `${base}..HEAD`]),
420
+ ])
421
+ const status = new Map<string, string>()
422
+ for (const r of parseNameStatus(statusOut)) status.set(r.to, DIFF_STATUS[r.code] ?? r.code)
423
+ const files: ReviewDiffFile[] = []
424
+ for (const line of numstatOut.split('\n')) {
425
+ const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
426
+ if (!m) continue
427
+ const { to } = parseStatPath(m[3]) // numstat renders a rename as `{old => new}`; keep the final path
428
+ files.push({ path: to, status: status.get(to) ?? 'modified', additions: m[1] === '-' ? 0 : +m[1], deletions: m[2] === '-' ? 0 : +m[2] })
429
+ }
430
+ return files
431
+ }
432
+
433
+ export function mergeConflicts(wtPath: string, mainRef = 'main'): Promise<boolean> {
434
+ return new Promise((resolve) => {
435
+ const env = { ...process.env }
436
+ delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
437
+ execFile('git', ['-C', wtPath, 'merge-tree', '--write-tree', '--no-messages', mainRef, 'HEAD'],
438
+ { encoding: 'utf8', env, maxBuffer: 1 << 24 },
439
+ // execFile sets err.code to the numeric EXIT code on a non-zero exit (1 = conflicts), or a string
440
+ // errno (e.g. 'ENOENT') if git can't be spawned — only the exit-1 case is a real conflict verdict.
441
+ (err) => resolve(!!err && err.code === 1))
442
+ })
443
+ }
444
+
445
+ // this worktree's spec ops vs main: one working-tree diff off the fork point (folds committed+staged+unstaged),
446
+ // a `status --porcelain` pass to add untracked spec.md, a third diff vs HEAD to mark what's committed.
447
+ export async function worktreeSpecDelta(wtPath: string, mainRef: string, baseHint?: string): Promise<NodeOp[]> {
448
+ const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
449
+ // fork point = where this worktree branched from main; '' (no common ancestor / unreadable ref) falls
450
+ // back to mainRef so we still surface changes rather than going silent. The caller (cachedDelta) already
451
+ // computes this same merge-base to key its cache, so it passes it in to avoid a redundant subprocess.
452
+ const base = baseHint || (await run(['merge-base', mainRef, 'HEAD'])).trim() || mainRef
453
+ // the three queries are independent — run them in parallel.
454
+ const [workOut, commOut, statusOut] = await Promise.all([
455
+ run(['diff', '--name-status', '-M', base, '--', '.spec']),
456
+ run(['diff', '--name-status', '-M', `${base}...HEAD`, '--', '.spec']),
457
+ run(['status', '--porcelain', '--untracked-files=all', '--', '.spec']),
458
+ ])
459
+ const work = parseNameStatus(workOut)
460
+ const committed = new Set(parseNameStatus(commOut).map((r) => r.to))
461
+ // --untracked-files=all: list every untracked spec.md individually (the default collapses a wholly
462
+ // new node's directory to `.spec/.../node/`, which we'd never recognise as a spec.md add).
463
+ const dirty = new Set<string>(), untracked: string[] = []
464
+ for (const line of statusOut.split('\n')) {
465
+ if (!line) continue
466
+ const xy = line.slice(0, 2)
467
+ let path = line.slice(3)
468
+ const arrow = path.indexOf(' -> '); if (arrow >= 0) path = path.slice(arrow + 4)
469
+ dirty.add(path)
470
+ if (xy === '??' && isSpecMd(path)) untracked.push(path)
471
+ }
472
+
473
+ const codeFor: Record<string, NodeOp['op']> = { A: 'added', M: 'edited', D: 'deleted', R: 'moved', C: 'added', T: 'edited' }
474
+ const ops: NodeOp[] = [], seen = new Set<string>()
475
+ for (const r of work) {
476
+ const path = r.code === 'D' ? r.from : r.to
477
+ if (!isSpecMd(path)) continue
478
+ seen.add(path)
479
+ const op = codeFor[r.code] ?? 'edited'
480
+ ops.push({
481
+ nodeId: nodeIdOf(path), op, path,
482
+ ...(op === 'moved' ? { fromPath: r.from, toPath: r.to } : {}),
483
+ committed: committed.has(r.to) || committed.has(r.from),
484
+ dirty: dirty.has(path) || dirty.has(r.from),
485
+ })
486
+ }
487
+ for (const path of untracked) {
488
+ if (seen.has(path)) continue
489
+ ops.push({ nodeId: nodeIdOf(path), op: 'added', path, committed: false, dirty: true })
490
+ }
491
+ return ops
492
+ }
package/src/guide.ts ADDED
@@ -0,0 +1,134 @@
1
+ const SETUP = `spex guide — run SpexCode on your own repo
2
+
3
+ The product model: install SpexCode ONCE, then use it across all your projects — an agent drives
4
+ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
5
+
6
+ 1. Install the CLI (one-time, global — this ONE checkout serves every project)
7
+ cd spec-cli && npm install && npm link # \`spex\` now runs from ANY directory
8
+ It always operates on the repo of your current directory — that cwd is the only "which repo" knob.
9
+
10
+ 2. Adopt a repo
11
+ cd <your-repo> && spex init # seeds .spec/ + git hooks (additive, never overwrites)
12
+ Works on any git repo. Edit .spec/project/spec.md to describe it, then grow child nodes
13
+ (each a dir with a spec.md + a \`code:\` list of the files it governs).
14
+
15
+ 3. Run the backend — it reads .spec + git from the cwd repo
16
+ spex serve # http://localhost:8787 (PORT=<n> for another endpoint)
17
+ Serve a different repo by running it from there; two repos at once = two \`spex serve\` on two PORTs.
18
+
19
+ 4. Open the dashboard — the SAME board for every project, pointed per project
20
+ cd spec-dashboard && npm install # once
21
+ API_URL=http://localhost:<port> npm run dev # point this board at step 3's backend
22
+ The board is a viewer: API_URL is how the shared install points at each project (one dev-server
23
+ per project). "dashboard": { "apiUrl": "..." } in spexcode.json is the default ONLY when the board
24
+ lives inside the project (the dogfood layout) — for a shared install, use API_URL.
25
+
26
+ 5. Govern your layout (optional)
27
+ spexcode.json sets lint's governedRoots/sourceExtensions and any non-default worktree layout.
28
+ \`spex lint\` must report 0 errors; coverage warnings are your adoption TODO (files no node claims yet).
29
+
30
+ The file formats an agent authors — run these for the full schema:
31
+ spex guide spec the spec.md format (frontmatter + body + the rules lint enforces)
32
+ spex guide yatsu the yatsu.md format (scenario schema + how loss is measured and filed)`
33
+
34
+ const SPEC = `spex guide spec — the spec.md file format
35
+
36
+ A spec node is a DIRECTORY under .spec/<project>/…/<id>/ holding a spec.md. The node's id is its leaf dir
37
+ name when that is globally unique, else the shortest parent-qualified path-suffix that disambiguates (so ids
38
+ are unique by construction) — the same id \`spex board\`, \`ack\`, and a node/<id> branch use. A spec states a node's PRESENT
39
+ intent at CONTRACT altitude — what it guarantees and why — and is rewritten in place as intent changes;
40
+ version history is git's job, never a changelog in the body.
41
+
42
+ FRONTMATTER (YAML between the opening and closing --- lines; every field optional, sensible defaults):
43
+ title display name. Defaults to the dir id.
44
+ desc one-line summary shown on the board.
45
+ hue board colour, 0–360. Default 210.
46
+ status pending | active | merged | drift. Usually DERIVED from git state — rarely hand-set.
47
+ code: files this node GOVERNS (is source of truth for) — ideally ONE, a YAML list of repo-relative
48
+ paths/dirs/*-globs. Drives drift + yatsu. Many nodes MAY govern the same file (ordinary
49
+ composition); a file governed by > maxOwners nodes warns (the \`owners\` rule — split it). Omit
50
+ for a pure-prose node: a cross-cutting contract no file owns.
51
+ related: files this node REFERENCES but does not own — a YAML list, same path forms. Carries coverage
52
+ (never drift, never yatsu, nothing to ack); it is the many-to-many net that claims the files
53
+ govern doesn't. Every listed path must exist (lint integrity error otherwise).
54
+ surface config/.config nodes only: system (folded into every agent's prompt) | slash (a /command) |
55
+ hook (a lifecycle hook handler — a co-located script the dispatcher runs on the harness events
56
+ in events:, ordered by order:, blocking when block: true). hook nodes may nest under a grouping
57
+ plugin (e.g. .config/core/<id>); surface is a field, discovered recursively.
58
+ events hook surface only: harness lifecycle events this node binds (YAML list — PreToolUse, Stop, …).
59
+ order hook surface only: integer; the dispatcher runs same-event hooks low to high.
60
+ block hook surface only: true if the hook may block its event (honored only on block-capable events).
61
+
62
+ BODY (Markdown after the frontmatter): the contract — intent, invariants, outward behaviour; NOT how the
63
+ code does it. Two optional level-2 headings split ground truth from detail:
64
+ ## raw source human-authored, rarely-changed intent — the loss function's target.
65
+ ## expanded spec agent-authored detail that must keep serving the raw source.
66
+ Bodies without those headings are read whole. Link sibling nodes with [[node-id]] (a dangling link is
67
+ fine — it marks a node worth writing).
68
+
69
+ WHAT lint CHECKS (spex lint; the pre-commit hook gates on errors):
70
+ integrity (error) every code: path exists.
71
+ living (error) no "## vN" changelog headings — the body is current-state.
72
+ altitude (warn) the body stays high-altitude: line/char budgets (~50 lines / 4200 chars), low
73
+ code-identifier density, no step-by-step phrasing. Over budget = rewrite higher.
74
+ coverage (warn) every source file is claimed by ≥1 node — via code: OR related: (related is the net).
75
+ drift (warn) a governed file has commits newer than the node's spec version — it may be stale.
76
+ Remedy: edit the spec to the new intent (re-versions the node), OR \`spex ack <node>
77
+ --reason "…"\` when only mechanics changed and the contract still holds.
78
+ owners (warn) a file governed by > maxOwners nodes (default 3) does too much — SPLIT it so each
79
+ governor owns its own module (or merge the nodes, or give it one foundation owner).
80
+
81
+ LIFECYCLE: author each node on a node/<id> branch, one node per commit; \`spex lint\` must reach 0 errors
82
+ before merge. \`spex init\` seeds the first tree; \`spex guide yatsu\` covers the sibling loss-signal file.`
83
+
84
+ const YATSU = `spex guide yatsu — the yatsu.md file format
85
+
86
+ A yatsu.md sits BESIDE a node's spec.md and says how to MEASURE the node's loss — the gap between live
87
+ behaviour and the spec. It is optional, but a FRONTEND node (its code: includes a UI file —
88
+ .jsx/.tsx/.vue/.svelte/.css, or the dashboard) with no yatsu.md is a blind spot: \`spex yatsu scan\` flags
89
+ it \`yatsu-uncovered\`. yatsu defines no DSL and RUNS NOTHING — the agent measures; yatsu keeps score.
90
+
91
+ FRONTMATTER: a \`scenarios:\` list (a YAML block sequence of mappings). Each scenario:
92
+ name REQUIRED. Unique within the file — it keys the sidecar and \`--scenario <name>\`.
93
+ description REQUIRED. What to check / how to measure it through the running product.
94
+ expected REQUIRED. What ZERO loss looks like — the target the measurement is compared against.
95
+ test optional. A repo path to a co-located runnable file (a playwright.spec.ts, a script)
96
+ the agent MAY run by hand. Not a driver — yatsu never executes it.
97
+ code optional. The file THIS scenario GOVERNS, ideally one (a comma list / flow list \`[a, b]\` is
98
+ allowed) — its own slice of the code freshness axis, so scenarios on one node go stale
99
+ independently. Absent → it inherits the node's \`code:\` list. A file governed by > maxOwners
100
+ scenarios warns \`yatsu-owners\` (split it). Each path must exist (a ghost → \`yatsu-schema\`).
101
+ related optional. Files this scenario REFERENCES but does not govern — same path forms. They do NOT
102
+ stale it (the freshness mirror of a spec node's govern/related). Each path must exist.
103
+ Multi-line prose uses YAML block scalars: \`|\` keeps newlines, \`>\` folds wrapped lines to spaces.
104
+ A yatsu.md OWNS nothing — only its scenarios govern and relate (see governed-related).
105
+
106
+ THE SCHEMA IS ENFORCED (closed field set, three required fields, unique names). A missing required field,
107
+ an unknown key (a typo like \`descripton:\`), a duplicate name, or no scenarios at all is rejected LOUD:
108
+ \`spex yatsu scan\` reports it as \`yatsu-schema\`, and the pre-commit \`yatsu check-staged\` BLOCKS the commit.
109
+
110
+ BODY (after the frontmatter): prose naming the measurement method — YATU ("You As The User"): the agent
111
+ looks at / calls the real product surface, not an internal helper chosen to make the proof easy.
112
+
113
+ MEASURING AND FILING: the agent runs the scenario however it likes (a browser screenshot, an API
114
+ transcript, a by-hand pass), compares the result to \`expected\`, and files it:
115
+ spex yatsu eval <node> --scenario <name> (--pass | --fail | --note <text>) [--image <png> | --result <txt>|-]
116
+ Frontend → \`--image <png>\` (visual evidence); backend → \`--result <txt>\` (a transcript; \`-\` reads stdin).
117
+
118
+ THE SCOREBOARD: readings live in yatsu.evals.ndjson beside the yatsu.md — one JSON line per measurement
119
+ (a second git-as-database axis). Freshness is derived live from git: a reading goes STALE when a governed
120
+ code file, the scenario (the yatsu.md), or the evaluator moves since it was filed.
121
+ spex yatsu scan [--changed] blind spots: yatsu-schema (malformed) · yatsu-drift (stale) ·
122
+ yatsu-missing (never measured) · yatsu-uncovered (frontend, no yatsu.md) ·
123
+ yatsu-owners (a file governed by > maxOwners scenarios — split it)
124
+ spex yatsu show <node> the reading timeline (verdict · freshness · evidence), newest first
125
+ spex yatsu clean GC the content-addressed evidence cache`
126
+
127
+ const TOPICS: Record<string, string> = { spec: SPEC, yatsu: YATSU }
128
+
129
+ export function guideText(topic?: string): string {
130
+ if (!topic) return SETUP
131
+ const t = TOPICS[topic]
132
+ if (t) return t
133
+ return `spex guide: no topic '${topic}'. Topics: spec, yatsu. Run \`spex guide\` (no topic) for the setup workflow.`
134
+ }