spexcode 0.1.5 → 0.2.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 (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +60 -19
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -113,41 +113,6 @@ export function worktreeSpecSig(wtPath: string): string {
113
113
  export type Version = { hash: string; date: string; reason: string; session: string | null }
114
114
  export type DiffStat = { additions: number; deletions: number; files: number }
115
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
116
  // ---- bulk spec history index ----
152
117
 
153
118
  export type HistoryIndex = {
@@ -174,14 +139,35 @@ function parseStatPath(token: string): { from: string; to: string } {
174
139
  return { from: token, to: token }
175
140
  }
176
141
 
177
- let indexCache: { head: string; idx: HistoryIndex } | null = null
142
+ // Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
143
+ // once — the backend checkout (board, loadSpecs) plus every session worktree ([[review-proof]]'s eval
144
+ // tab roots its readings at the session's branch). A single-slot cache thrashes between those roots:
145
+ // each eval-tab request evicts the board's entry and vice versa, so every request re-runs a full-history
146
+ // `git log` and re-parses it on the event loop — which is what starves every other request (the board,
147
+ // remark posts) under load. So the cache is a small LRU keyed by HEAD (same head ⇒ same index, whatever
148
+ // the root), holding the in-flight PROMISE so concurrent requests for one head share a single build.
149
+ const INDEX_SLOTS = 16
150
+ function lruGet<V>(m: Map<string, V>, k: string): V | undefined {
151
+ const v = m.get(k)
152
+ if (v !== undefined) { m.delete(k); m.set(k, v) } // refresh recency
153
+ return v
154
+ }
155
+ function lruPut<V>(m: Map<string, V>, k: string, v: V): void {
156
+ m.set(k, v)
157
+ while (m.size > INDEX_SLOTS) m.delete(m.keys().next().value!)
158
+ }
159
+
160
+ const indexCache = new Map<string, Promise<HistoryIndex>>()
178
161
 
179
- export async function historyIndex(root: string): Promise<HistoryIndex> {
162
+ export function historyIndex(root: string): Promise<HistoryIndex> {
180
163
  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
164
+ if (!head) return buildIndex(root)
165
+ const hit = lruGet(indexCache, head)
166
+ if (hit) return hit
167
+ const p = buildIndex(root)
168
+ p.catch(() => { indexCache.delete(head) }) // don't pin a failed build
169
+ lruPut(indexCache, head, p)
170
+ return p
185
171
  }
186
172
 
187
173
  // resolve HEAD for cache-keying, '' if unreadable (fails the cache test → recompute); warns once.
@@ -232,9 +218,6 @@ async function buildIndex(root: string): Promise<HistoryIndex> {
232
218
  return { versions, stats }
233
219
  }
234
220
 
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
221
  // pure lookups over a prebuilt index (no git). rowsFor drops pure-rename rows (0/0) so a move isn't a version.
239
222
  export function rowsFor(idx: HistoryIndex, relPath: string): Version[] {
240
223
  const rows = idx.versions.get(relPath) ?? []
@@ -266,18 +249,6 @@ export async function pathsStats(root: string, paths: string[]): Promise<Map<str
266
249
  return m
267
250
  }
268
251
 
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
252
  // the patch a spec.md got in one commit (vs parent); resolve its path AT that commit (reparents move it)
282
253
  // via the stable leaf dir `…/<id>/spec.md`, then `git show` that path. `-M` keeps a rename+edit's body. '' on error.
283
254
  export async function fileDiffAt(root: string, relPath: string, hash: string): Promise<string> {
@@ -288,35 +259,44 @@ export async function fileDiffAt(root: string, relPath: string, hash: string): P
288
259
  return gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '-M', '--format=', hash, '--', at])
289
260
  }
290
261
 
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.
262
+ // A cached `git log` over HEAD (HEAD-keyed like historyIndex), enriched with parent edges so "newer than
263
+ // the spec" is answered by true DAG reachability, never by a log-position/date compare (a linear
264
+ // order can't encode a branching history's partial order and silently under-reports back-dated
265
+ // branches, adoption). driftFor()/ancestorsOf() are then pure in-memory lookups. `acks`/`specNodes`
266
+ // carry the Spec-OK convention (see driftFor): acks[hash] = node ids declared still-valid via
267
+ // `Spec-OK:` trailers; specNodes[hash] = node ids whose spec.md it touched.
295
268
  export type DriftIndex = {
296
- pos: Map<string, number>
269
+ ord: Map<string, number> // hash -> dense id from the walk: a bitset slot, NEVER an order to compare
270
+ parents: Map<string, string[]> // hash -> parent hashes (the DAG edges, from the same walk)
297
271
  fileCommits: Map<string, string[]>
298
272
  acks: Map<string, Set<string>> // commit hash -> node ids acknowledged via `Spec-OK:` trailers
299
273
  specNodes: Map<string, Set<string>> // commit hash -> node ids whose spec.md it touched (its versions)
274
+ anc: Map<string, Uint8Array> // memoized reachability bitsets, lazily built per queried sha
300
275
  }
301
- let driftIdxCache: { head: string; idx: DriftIndex } | null = null
276
+ const driftIdxCache = new Map<string, Promise<DriftIndex>>() // HEAD-keyed LRU, same shape as indexCache above
302
277
 
303
278
  async function buildDriftIndex(root: string): Promise<DriftIndex> {
304
- const pos = new Map<string, number>(), fileCommits = new Map<string, string[]>()
279
+ const ord = new Map<string, number>(), parents = new Map<string, string[]>()
280
+ const fileCommits = new Map<string, string[]>()
305
281
  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).
282
+ const idx: DriftIndex = { ord, parents, fileCommits, acks, specNodes, anc: new Map() }
283
+ // RS-delimited records: `<hash>US<parents>US<comma-joined Spec-OK values>` on line 1, then the
284
+ // --name-only file list. `valueonly,separator` collapses the trailer block to one line so it never
285
+ // collides with the file names below it (a raw `%b` body would interleave and be unparseable).
309
286
  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 }
287
+ `--format=${RS}%H${US}%P${US}%(trailers:key=Spec-OK,valueonly,separator=%x2C)`, 'HEAD'])
288
+ if (!out) return idx
312
289
  let i = 0
313
290
  for (const rec of out.split(RS)) {
314
291
  const r = rec.replace(/^\n/, '')
315
292
  if (!r) continue
316
293
  const lines = r.split('\n')
317
- const [hash, ackStr = ''] = lines[0].split(US)
294
+ const [hash, parentStr = '', ackStr = ''] = lines[0].split(US)
318
295
  if (!hash) continue
319
- if (!pos.has(hash)) pos.set(hash, i++)
296
+ if (!ord.has(hash)) {
297
+ ord.set(hash, i++)
298
+ parents.set(hash, parentStr.split(' ').filter(Boolean))
299
+ }
320
300
  const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean))
321
301
  if (ackSet.size) acks.set(hash, ackSet)
322
302
  for (const line of lines.slice(1)) {
@@ -329,42 +309,75 @@ async function buildDriftIndex(root: string): Promise<DriftIndex> {
329
309
  }
330
310
  }
331
311
  }
332
- return { pos, fileCommits, acks, specNodes }
312
+ return idx
333
313
  }
334
- export async function driftIndex(root: string): Promise<DriftIndex> {
314
+ export function driftIndex(root: string): Promise<DriftIndex> {
335
315
  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
316
+ if (!head) return buildDriftIndex(root)
317
+ const hit = lruGet(driftIdxCache, head)
318
+ if (hit) return hit
319
+ const p = buildDriftIndex(root)
320
+ p.catch(() => { driftIdxCache.delete(head) })
321
+ lruPut(driftIdxCache, head, p)
322
+ return p
323
+ }
324
+ // the reachability set of `sha` — itself plus every ancestor — as a bitset over the walk's dense ids.
325
+ // Built once per queried sha by following parent edges in memory (no git fork), memoized on the index;
326
+ // a bitset costs history-length BITS, so hundreds of cached shas stay cheap on the board hot path.
327
+ // undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
328
+ // ref) — callers apply their own conservative rule to that "can't prove" case.
329
+ export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefined {
330
+ const hit = idx.anc.get(sha)
331
+ if (hit) return hit
332
+ const start = idx.ord.get(sha)
333
+ if (start === undefined) return undefined
334
+ const bits = new Uint8Array((idx.ord.size + 7) >> 3)
335
+ bits[start >> 3] |= 1 << (start & 7)
336
+ const stack = [sha]
337
+ while (stack.length) {
338
+ for (const p of idx.parents.get(stack.pop()!) ?? []) {
339
+ const o = idx.ord.get(p)
340
+ if (o === undefined) continue // shallow-clone boundary: an unwalked parent ends the chain
341
+ const m = 1 << (o & 7)
342
+ if (bits[o >> 3] & m) continue
343
+ bits[o >> 3] |= m
344
+ stack.push(p)
345
+ }
346
+ }
347
+ idx.anc.set(sha, bits)
348
+ return bits
340
349
  }
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.
350
+ export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boolean {
351
+ const o = idx.ord.get(sha)
352
+ return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
353
+ }
354
+
355
+ // pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
356
+ // in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
343
357
  //
344
358
  // `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.
359
+ // (specNodes[sinceHash]) name the node being measured; an ack counts only if its `Spec-OK:` set names
360
+ // one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that is itself an ancestor of the
361
+ // version can't speak for it (a re-version invalidates older acks); a valid ack quiets exactly the
362
+ // commits reachable from it. An off-history `sinceHash` → 0: no basis on HEAD to measure from.
347
363
  export function driftFor(idx: DriftIndex, sinceHash: string, path: string): number {
348
364
  if (!sinceHash) return 0
349
- const sp = idx.pos.get(sinceHash)
350
- if (sp === undefined) return 0
365
+ const base = ancestorsOf(idx, sinceHash)
366
+ if (!base) return 0
351
367
  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
368
+ const ackCover: Uint8Array[] = []
356
369
  if (targets) {
357
370
  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)
371
+ if (inAncestors(idx, base, h)) continue
372
+ if (![...targets].some((t) => ackSet.has(t))) continue
373
+ const a = ancestorsOf(idx, h)
374
+ if (a) ackCover.push(a)
361
375
  }
362
376
  }
363
377
  let n = 0
364
378
  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
379
+ if (inAncestors(idx, base, h)) continue // reachable from the version → not drift
380
+ if (ackCover.some((a) => inAncestors(idx, a, h))) continue // covered by an ackacknowledged
368
381
  n++
369
382
  }
370
383
  return n
@@ -27,9 +27,11 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
27
27
  spexcode.json sets lint's governedRoots/sourceExtensions and any non-default worktree layout.
28
28
  \`spex lint\` must report 0 errors; coverage warnings are your adoption TODO (files no node claims yet).
29
29
 
30
- The file formats an agent authors run these for the full schema:
30
+ Look these up on demand — the formats an agent authors, and the settings it configures:
31
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)`
32
+ spex guide yatsu the yatsu.md format (scenario schema + how loss is measured and filed)
33
+ spex guide config the spexcode.json / spexcode.local.json settings (launchers, dashboard icon, lint
34
+ budgets, layout) — every field, and which of the two files it belongs in`
33
35
 
34
36
  const SPEC = `spex guide spec — the spec.md file format
35
37
 
@@ -51,7 +53,7 @@ FRONTMATTER (YAML between the opening and closing --- lines; every field optiona
51
53
  related: files this node REFERENCES but does not own — a YAML list, same path forms. Carries coverage
52
54
  (never drift, never yatsu, nothing to ack); it is the many-to-many net that claims the files
53
55
  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) |
56
+ surface config/.config nodes only: system (folded into every agent's prompt) | command (a /command) |
55
57
  hook (a lifecycle hook handler — a co-located script the dispatcher runs on the harness events
56
58
  in events:, ordered by order:, blocking when block: true). hook nodes may nest under a grouping
57
59
  plugin (e.g. .config/core/<id>); surface is a field, discovered recursively.
@@ -92,6 +94,11 @@ FRONTMATTER: a \`scenarios:\` list (a YAML block sequence of mappings). Each sce
92
94
  name REQUIRED. Unique within the file — it keys the sidecar and \`--scenario <name>\`.
93
95
  description REQUIRED. What to check / how to measure it through the running product.
94
96
  expected REQUIRED. What ZERO loss looks like — the target the measurement is compared against.
97
+ tags REQUIRED. ≥1 classification tag (a comma list / flow list \`[a, b]\`), each drawn from the
98
+ configured library (\`lint.scenarioTags\` in spexcode.json; ships
99
+ \`frontend-e2e, backend-api, cli, desktop, mobile\`). A tag outside the library is rejected —
100
+ use an existing one, or add it to the library to mint it. Tags classify a scenario (surface,
101
+ device) so it can be filtered and, later, routed to the right driver.
95
102
  test optional. A repo path to a co-located runnable file (a playwright.spec.ts, a script)
96
103
  the agent MAY run by hand. Not a driver — yatsu never executes it.
97
104
  code optional. The file THIS scenario GOVERNS, ideally one (a comma list / flow list \`[a, b]\` is
@@ -103,18 +110,29 @@ FRONTMATTER: a \`scenarios:\` list (a YAML block sequence of mappings). Each sce
103
110
  Multi-line prose uses YAML block scalars: \`|\` keeps newlines, \`>\` folds wrapped lines to spaces.
104
111
  A yatsu.md OWNS nothing — only its scenarios govern and relate (see governed-related).
105
112
 
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.
113
+ THE SCHEMA IS ENFORCED (closed field set, four required fields, unique names, tags within the library). A
114
+ missing required field, an unknown key (a typo like \`descripton:\`), a duplicate name, an out-of-library
115
+ tag, or no scenarios at all is rejected LOUD: \`spex yatsu scan\` reports it as \`yatsu-schema\`, and the
116
+ pre-commit \`yatsu check-staged\` BLOCKS the commit.
109
117
 
110
118
  BODY (after the frontmatter): prose naming the measurement method — YATU ("You As The User"): the agent
111
119
  looks at / calls the real product surface, not an internal helper chosen to make the proof easy.
112
120
 
113
121
  MEASURING AND FILING: the agent runs the scenario however it likes (a browser screenshot, an API
114
122
  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>|-]
123
+ spex yatsu eval <node> --scenario <name> (--pass | --fail) [--note <text>] [--image <png> | --result <txt>|-]
124
+ The verdict is \`--pass\` or \`--fail\` (a measurement must commit to one — an unmeasured scenario is \`missing\`,
125
+ not a hedged fail). \`--note <text>\` is an OPTIONAL one-line annotation on either (why it failed, how far a
126
+ pass sits from ideal); it does NOT replace evidence — the image/transcript is the captured actual behaviour.
116
127
  Frontend → \`--image <png>\` (visual evidence); backend → \`--result <txt>\` (a transcript; \`-\` reads stdin).
117
128
 
129
+ A botched filing (a junk e2e/smoke run, a wrong verdict) is undone through the SAME surface:
130
+ spex yatsu retract <node> [--scenario <name>] [--last | --ts <iso>] [--note <why>]
131
+ retract APPENDS a retraction event to the sidecar (never deletes a line — the trace stays, git records
132
+ who/when/why); the scoreboard then drops the retracted reading everywhere: the previous reading becomes
133
+ the latest again, or the scenario honestly returns to \`missing\`. Default target is the scenario's latest
134
+ reading (\`--last\` makes that explicit; repeat to peel junk back one filing at a time); \`--ts\` pins one.
135
+
118
136
  THE SCOREBOARD: readings live in yatsu.evals.ndjson beside the yatsu.md — one JSON line per measurement
119
137
  (a second git-as-database axis). Freshness is derived live from git: a reading goes STALE when a governed
120
138
  code file, the scenario (the yatsu.md), or the evaluator moves since it was filed.
@@ -124,11 +142,156 @@ code file, the scenario (the yatsu.md), or the evaluator moves since it was file
124
142
  spex yatsu show <node> the reading timeline (verdict · freshness · evidence), newest first
125
143
  spex yatsu clean GC the content-addressed evidence cache`
126
144
 
127
- const TOPICS: Record<string, string> = { spec: SPEC, yatsu: YATSU }
145
+ const CONFIG = `spex guide config SpexCode's runtime settings (spexcode.json / spexcode.local.json)
146
+
147
+ SpexCode reads its runtime settings from TWO optional JSON files at the repo root. There is no imperative
148
+ \`spex config set\` — an agent CONFIGURES SpexCode by EDITING these files directly. The two split by
149
+ PORTABILITY, and picking the right one is the whole discipline:
150
+
151
+ spexcode.json COMMITTED — portable, shared by everyone on the repo. Layout, policy, dashboard
152
+ identity, lint budgets, launcher NAMES. "Git is the database": tracked so the
153
+ team shares ONE configuration.
154
+ spexcode.local.json GITIGNORED — host-specific, never committed. Absolute launcher paths, cert/secret
155
+ paths, private-overlay mode. Layered OVER spexcode.json (see MERGE below); an env
156
+ var (SPEXCODE_CLAUDE_CMD, …) still overrides both at its read site.
157
+
158
+ Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
159
+ icon, a lint budget, a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
160
+ PATH of a launcher wrapper, a TLS cert path, private mode are machine facts → gitignored spexcode.local.json.
161
+ Both files are optional; omit any field to take its default.
162
+
163
+ MERGE: spexcode.local.json is layered over spexcode.json ONE LEVEL DEEP — per top-level section (dashboard,
164
+ sessions, …), the two objects are shallow-merged with LOCAL WINNING per key; sections only one file names
165
+ pass through untouched. This is exactly what lets a launcher's portable NAME reference (defaultLauncher)
166
+ sit in the committed file while its host-specific DEFINITION (with the abs cmd) sits in the local file —
167
+ see LAUNCHERS.
168
+
169
+ ── LAYOUT (spexcode.json — portable; set only for a NON-DEFAULT repo layout) ──
170
+ main path to the source-of-truth checkout. Default: the \`main\` worktree.
171
+ mainBranch the source-of-truth BRANCH worktrees fork from. Default: auto-detected.
172
+ branchPrefix how a node branch is named. Default "node/".
173
+ Example — a repo whose trunk is \`staging\`, not \`main\`:
174
+ { "mainBranch": "staging" }
175
+
176
+ ── DASHBOARD (spexcode.json — portable project identity) ──
177
+ dashboard.title browser-tab name. Default: the repo-root basename.
178
+ dashboard.icon browser-tab favicon: an emoji ("🔭") OR an Iconify name ("mdi:rocket-launch").
179
+ dashboard.apiUrl the per-project backend the board proxies to (read frontend-side). For a SHARED
180
+ install prefer the API_URL env var; apiUrl here is the default only when the board
181
+ lives inside the project.
182
+ Example:
183
+ { "dashboard": { "title": "MyApp specs", "icon": "mdi:rocket-launch" } }
184
+
185
+ ── SESSIONS / WORKERS ──
186
+ sessions.maxActive concurrency cap — max agents AUTONOMOUSLY PROGRESSING at once (default 8).
187
+ Counts compute slots, not total sessions: idle/asking/review/done do not
188
+ occupy one. A policy number → committed spexcode.json; omit it to use the
189
+ default, or tune higher/lower for the project's usual host.
190
+ sessions.claudeCmd the UNNAMED default worker launcher for Claude (default
191
+ 'claude --dangerously-skip-permissions'); env SPEXCODE_CLAUDE_CMD overrides.
192
+ sessions.codexCmd the UNNAMED default worker launcher for Codex (default 'codex --yolo'); env
193
+ SPEXCODE_CODEX_CMD overrides.
194
+ sessions.launchers NAMED launcher profiles (see LAUNCHERS).
195
+ sessions.defaultLauncher the launcher name a create with no explicit --launcher/dropdown pick uses
196
+ (else the unnamed claudeCmd/codexCmd path). A portable NAME → committed.
197
+ A claudeCmd/codexCmd or a launcher \`cmd\` that is a HOST-SPECIFIC ABSOLUTE PATH belongs in
198
+ spexcode.local.json — the committed file must stay free of machine paths.
199
+
200
+ ── LAUNCHERS (the profile block, split across the two files) ──
201
+ A named launcher profile fixes BOTH a session's harness AND its exact launch command; a create picks one
202
+ by name, and the chosen name is persisted on the record so a resume reuses the same auth. Shape:
203
+ "launchers": { "<name>": { "harness": "claude" | "codex", "cmd": "<launch command>" } }
204
+ \`harness\` defaults to "claude"; \`cmd\` is required. Because \`cmd\` is a machine fact (an abs wrapper path),
205
+ the DEFINITION lives in the gitignored spexcode.local.json, while the portable defaultLauncher NAME sits
206
+ in the committed spexcode.json — the merge keeps both:
207
+
208
+ spexcode.json (committed — the portable name reference)
209
+ { "sessions": { "defaultLauncher": "gpt5" } }
210
+
211
+ spexcode.local.json (gitignored — the host-specific definitions)
212
+ {
213
+ "sessions": {
214
+ "launchers": {
215
+ "gpt5": { "harness": "codex", "cmd": "/Users/me/bin/reclaude-codex --yolo" },
216
+ "claude-prod": { "harness": "claude", "cmd": "/Users/me/bin/reclaude --dangerously-skip-permissions" }
217
+ }
218
+ }
219
+ }
220
+
221
+ ── SERVE (spexcode.json — public-exposure for \`spex serve --public\`) ──
222
+ serve.public.enabled turn public mode on without the --public flag.
223
+ serve.public.http drop TLS (the --http escape hatch) — the password then travels in cleartext.
224
+ serve.public.tls { "cert": "<path>", "key": "<path>" } — PATHS to your own cert/key; omit for a
225
+ cached self-signed default. If the paths are host-specific, put them in
226
+ spexcode.local.json.
227
+ The gateway password is NEVER read from these files (flag/env only), so serve.public stays committable.
228
+
229
+ ── ISSUES (spexcode.json — portable policy) ──
230
+ issues.enabled the issues-workflow on/off switch (default ON). OFF silences the post-merge nudge and
231
+ hides the dashboard view; the CLI toggle is \`spex issues on|off\`.
232
+
233
+ ── LINT (spexcode.json — a top-level "lint" key; budgets are portable, so committed only) ──
234
+ lint.governedRoots dirs whose source files must each be governed by a spec (coverage).
235
+ '.' = the whole project (only git-TRACKED files). Default
236
+ ["spec-dashboard/src", "spec-cli/src"].
237
+ lint.sourceExtensions extensions coverage treats as source. Default ["ts","tsx","js","jsx"].
238
+ lint.testGlobs globs EXCLUDED from coverage (default ["**/*.test.*"]; [] to govern tests too).
239
+ lint.identifierExtensions extensions the altitude bare-filename signal recognises.
240
+ lint.altitude body budgets: { lineBudget, charBudget, sizeable, dense, steps }
241
+ (defaults 50 / 4200 / 35 / 1.3 / 3).
242
+ lint.maxChildren breadth budget: warn at >= this many direct children (default 8).
243
+ lint.driftErrorThreshold commit-local gate HARD-BLOCKS a commit touching a node >= this many commits
244
+ behind (default 3).
245
+ lint.maxOwners warn when a file is governed by > this many nodes (default 3).
246
+ lint.scenarioTags the closed vocabulary a yatsu scenario's tags: must draw from (default
247
+ ["frontend-e2e","backend-api","cli","desktop","mobile"]); extend to mint a tag.
248
+ Example — govern your own source dir and loosen the altitude budget:
249
+ { "lint": { "governedRoots": ["src"], "altitude": { "lineBudget": 70 } } }
250
+
251
+ ── OTHER (spexcode.json unless noted) ──
252
+ preset the SELECTED init preset — which cumulative .config tier \`spex init\` seeds (default
253
+ 'default'; seed-time only, read by init.ts).
254
+ harnesses which harness targets \`spex materialize\` delivers into — native ids ("claude"|"codex") or a
255
+ { "plugin": "<folder>" } bundle. Default (omitted): all native harnesses.
256
+ private (spexcode.local.json ONLY) private-overlay mode — when true, \`spex materialize\` leaves ZERO
257
+ trace in the host's TRACKED files: managed ignore entries go to .git/info/exclude and any
258
+ tracked contract file is marked skip-worktree. Trades away git-derived spec version history.
259
+ A HOST decision → never the committed file. See PRIVATE MODE below.
260
+
261
+ ── PRIVATE MODE (default ⇄ private — the two delivery modes) ──
262
+ DEFAULT mode (private absent/false): materialize commits .spec + spexcode.json and writes its ignore list as
263
+ a managed block in the TRACKED .gitignore — transparent, but every collaborator sees it. PRIVATE mode
264
+ (spexcode.local.json { "private": true }): the SAME contract reaches the agent, but the ignore list goes to
265
+ the per-clone .git/info/exclude and any host-tracked CLAUDE.md/AGENTS.md is skip-worktree'd — so \`git status\`
266
+ stays clean and nothing enters shared history.
267
+
268
+ Switch: edit spexcode.local.json ({ "private": true } on / false or remove = off), then \`spex materialize\`
269
+ (the hook gate also re-runs it on the next agent turn).
270
+ Reversible + idempotent: the two modes fully CANCEL OUT. default→private→default (or private→default→private)
271
+ converges to the SAME on-disk state as running that mode once — each mode re-asserts the inverse of
272
+ the other (exclude block ⇄ .gitignore block, skip-worktree set ⇄ cleared). Switch order never
273
+ matters; running a mode twice changes nothing.
274
+
275
+ MANUAL STEP (the only one materialize can't do for you — it also PRINTS this when needed):
276
+ .git/info/exclude hides UNTRACKED paths only. If you adopted DEFAULT mode first, .spec + spexcode.json are
277
+ already committed, so private mode can't hide them until you un-track them ONCE:
278
+ git rm -r --cached .spec spexcode.json # keeps the files on disk, stops tracking them
279
+ (then commit that on your branch). A private-from-the-start adoption never needs this.
280
+
281
+ FOOTGUN (skip-worktree): a host-tracked CLAUDE.md/AGENTS.md is skip-worktree'd in private mode, so a later
282
+ \`git pull\` that touches it can complain. Fix: flip back to DEFAULT mode (or \`git update-index
283
+ --no-skip-worktree CLAUDE.md AGENTS.md\`), pull, then re-materialize.`
284
+
285
+ const TOPICS: Record<string, string> = { spec: SPEC, yatsu: YATSU, config: CONFIG }
286
+
287
+ // every guide page ends by naming the OTHER help layer, so a reader never dead-ends here: guide is
288
+ // the skill layer (workflows · formats · settings); command usage lives in help.ts's two layers.
289
+ const FOOTER = `\n\n(This is the skill layer. Command usage: \`spex help\` for the map, \`spex help <command>\` for one command.)`
128
290
 
129
- export function guideText(topic?: string): string {
130
- if (!topic) return SETUP
291
+ // null = unknown topic: the caller fails loud (exit non-zero) while still naming the layers to go
292
+ // back to — an unknown topic must never read as a successful page ([[cli-surface]]'s dead-end rule).
293
+ export function guideText(topic?: string): string | null {
294
+ if (!topic) return SETUP + FOOTER
131
295
  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.`
296
+ return t ? t + FOOTER : null
134
297
  }
@@ -0,0 +1,63 @@
1
+ import { HARNESSES, type Harness, type HarnessId } from './harness.js'
2
+
3
+ // @@@ harness-select - the DECLARATIVE choice of WHICH harness targets `spex materialize` delivers the
4
+ // SpexCode system into. The selection is persistent config (spexcode.json's `harnesses`), NOT a one-shot
5
+ // flag, because materialize is driven by a content-hash gate (re-run on every .config edit), so the intent
6
+ // must live where every re-materialize can read it. This module owns ONLY the vocabulary + validation; the
7
+ // per-harness write/clean mechanics live on the [[harness-adapter]], the render loop on [[harness-delivery]].
8
+
9
+ // a resolved DELIVERY TARGET. Either a NATIVE harness (claude/codex — its adapter writes shims/contract/trust
10
+ // directly), or a PLUGIN bundle dropped into a host-agent-scanned folder. The plugin EMITTER is a later node;
11
+ // here a plugin target is only validated, it produces no artifact yet.
12
+ export type HarnessTarget =
13
+ | { kind: 'native'; id: HarnessId }
14
+ | { kind: 'plugin'; folder: string }
15
+
16
+ // the zero-config default: deliver to EVERY native harness, no plugin.
17
+ export const DEFAULT_HARNESS_IDS: readonly HarnessId[] = HARNESSES.map((h) => h.id)
18
+ const KNOWN: readonly string[] = HARNESSES.map((h) => h.id)
19
+
20
+ // parse + validate the spexcode.json `harnesses` field into resolved targets. FAIL LOUD on an illegal set —
21
+ // materialize and init both gate on this so a bad config never silently delivers the wrong thing. `raw` is the
22
+ // JSON value as written; undefined/null → the default native set.
23
+ export function resolveHarnessTargets(raw: unknown): HarnessTarget[] {
24
+ if (raw === undefined || raw === null) return DEFAULT_HARNESS_IDS.map((id) => ({ kind: 'native', id }))
25
+ if (!Array.isArray(raw))
26
+ throw new Error(`spexcode.json "harnesses" must be an ARRAY of targets (got ${typeof raw}). Members are native ids (${KNOWN.join(', ')}) or {"plugin":"<folder>"}; omit the field to default to [${DEFAULT_HARNESS_IDS.join(', ')}].`)
27
+ if (raw.length === 0)
28
+ throw new Error(`spexcode.json "harnesses" is EMPTY — list at least one target, or remove the field to default to [${DEFAULT_HARNESS_IDS.join(', ')}].`)
29
+ const targets: HarnessTarget[] = []
30
+ for (const m of raw) {
31
+ if (typeof m === 'string') {
32
+ if (m === 'plugin')
33
+ throw new Error(`spexcode.json "harnesses": a plugin target needs an EXPLICIT landing folder — write {"plugin":"<folder>"} (e.g. {"plugin":".zcode"}), not the bare string "plugin", because each host agent scans a different plugins dir.`)
34
+ if (!KNOWN.includes(m))
35
+ throw new Error(`spexcode.json "harnesses": unknown harness id "${m}" — known native ids are ${KNOWN.join(', ')}, or use {"plugin":"<folder>"}.`)
36
+ targets.push({ kind: 'native', id: m as HarnessId })
37
+ } else if (m && typeof m === 'object' && !Array.isArray(m) && 'plugin' in m) {
38
+ const folder = (m as { plugin?: unknown }).plugin
39
+ if (typeof folder !== 'string' || !folder.trim())
40
+ throw new Error(`spexcode.json "harnesses": a {"plugin":…} target needs a NON-EMPTY folder string (e.g. {"plugin":".zcode"}) — each host agent scans a different plugins dir, so the folder must be explicit.`)
41
+ targets.push({ kind: 'plugin', folder: folder.trim() })
42
+ } else {
43
+ throw new Error(`spexcode.json "harnesses": each member must be a native id string (${KNOWN.join(', ')}) or a {"plugin":"<folder>"} object — got ${JSON.stringify(m)}.`)
44
+ }
45
+ }
46
+ // PLUGIN EXCLUSIVITY: a plugin bundle is a SUPERSET delivery to its host agent, so pairing it with any
47
+ // native harness double-delivers. So a set with a plugin may carry NO native harness.
48
+ const natives = targets.filter((t): t is { kind: 'native'; id: HarnessId } => t.kind === 'native')
49
+ if (targets.some((t) => t.kind === 'plugin') && natives.length)
50
+ throw new Error(`spexcode.json "harnesses": a plugin target is EXCLUSIVE — it cannot coexist with native harnesses (${natives.map((t) => t.id).join(', ')}). A plugin bundle already delivers the whole system to its host agent, so pairing it with a native harness double-delivers. Choose EITHER native harnesses OR plugin target(s).`)
51
+ return targets
52
+ }
53
+
54
+ // split the live HARNESSES adapters by whether the resolved targets SELECT them: selected get write()n,
55
+ // unselected get clean()ed (pruned). `plugins` carries the plugin targets (no emitter yet — a later node).
56
+ export function partitionHarnesses(targets: HarnessTarget[]): { selected: Harness[]; unselected: Harness[]; plugins: { folder: string }[] } {
57
+ const selectedIds = new Set(targets.filter((t) => t.kind === 'native').map((t) => (t as { id: HarnessId }).id))
58
+ return {
59
+ selected: HARNESSES.filter((h) => selectedIds.has(h.id)),
60
+ unselected: HARNESSES.filter((h) => !selectedIds.has(h.id)),
61
+ plugins: targets.filter((t) => t.kind === 'plugin').map((t) => ({ folder: (t as { folder: string }).folder })),
62
+ }
63
+ }