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.
- package/bin/spex.mjs +15 -0
- package/dashboard-dist/assets/index-B60MILFg.js +139 -0
- package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
- package/dashboard-dist/index.html +16 -0
- package/package.json +35 -0
- package/src/board.ts +119 -0
- package/src/cli.ts +487 -0
- package/src/client.ts +102 -0
- package/src/gateway.ts +241 -0
- package/src/git.ts +492 -0
- package/src/guide.ts +134 -0
- package/src/harness.ts +674 -0
- package/src/hooks.ts +41 -0
- package/src/index.ts +233 -0
- package/src/init.ts +120 -0
- package/src/layout.ts +246 -0
- package/src/lint.ts +206 -0
- package/src/login-page.ts +79 -0
- package/src/materialize.ts +85 -0
- package/src/pty-bridge.ts +235 -0
- package/src/ranker.ts +129 -0
- package/src/resilience.ts +41 -0
- package/src/search.bench.mjs +47 -0
- package/src/search.ts +24 -0
- package/src/self.ts +256 -0
- package/src/sessions.ts +1469 -0
- package/src/slash-commands.ts +242 -0
- package/src/specs.ts +331 -0
- package/src/supervise.ts +158 -0
- package/src/uploads.ts +31 -0
- package/templates/hooks/pre-commit +57 -0
- package/templates/hooks/prepare-commit-msg +14 -0
- package/templates/spec/project/.config/core/idle/idle.sh +15 -0
- package/templates/spec/project/.config/core/idle/spec.md +13 -0
- package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
- package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
- package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
- package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
- package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
- package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
- package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
- package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
- package/templates/spec/project/.config/core/spec.md +13 -0
- package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
- package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
- package/templates/spec/project/.config/extract/spec.md +60 -0
- package/templates/spec/project/.config/forge-link/spec.md +9 -0
- package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
- package/templates/spec/project/.config/regroup/spec.md +25 -0
- package/templates/spec/project/.config/scenario/spec.md +32 -0
- package/templates/spec/project/.config/spec.md +15 -0
- package/templates/spec/project/.config/supervisor/spec.md +8 -0
- package/templates/spec/project/.config/tidy/spec.md +25 -0
- package/templates/spec/project/spec.md +19 -0
- 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
|
+
}
|