spexcode 0.4.0 → 0.4.1

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 (45) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/anchors.ts +53 -12
  3. package/spec-cli/src/cli.ts +9 -5
  4. package/spec-cli/src/gateway.ts +8 -1
  5. package/spec-cli/src/graph.ts +13 -7
  6. package/spec-cli/src/guide.ts +52 -21
  7. package/spec-cli/src/harness.ts +85 -34
  8. package/spec-cli/src/help.ts +20 -14
  9. package/spec-cli/src/index.ts +25 -7
  10. package/spec-cli/src/lint.ts +91 -49
  11. package/spec-cli/src/migrate-table.ts +16 -6
  12. package/spec-cli/src/session-timeline.ts +148 -0
  13. package/spec-cli/src/sessions.ts +29 -4
  14. package/spec-cli/src/specs.ts +35 -17
  15. package/spec-cli/templates/hooks/prepare-commit-msg +6 -1
  16. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +16 -0
  17. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +17 -0
  18. package/spec-cli/templates/spec/project/.plugins/spec.md +3 -3
  19. package/spec-dashboard/dist/assets/{Dashboard-CTcH2eW9.js → Dashboard-C_fGmOKK.js} +3 -3
  20. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +2 -0
  21. package/spec-dashboard/dist/assets/{FoldToggle-CVFbBpyW.js → FoldToggle-x9gtO1OQ.js} +1 -1
  22. package/spec-dashboard/dist/assets/{IssuesPage-kULjonqj.js → IssuesPage-5f_vL-JV.js} +1 -1
  23. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +1 -0
  24. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +66 -0
  25. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +13 -0
  26. package/spec-dashboard/dist/assets/{Settings-BL6FV_8S.js → Settings-DKb5Ji_X.js} +1 -1
  27. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +41 -0
  28. package/spec-dashboard/dist/assets/index-BbMkwuix.css +1 -0
  29. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +6 -0
  30. package/spec-dashboard/dist/index.html +2 -2
  31. package/spec-eval/src/cli.ts +32 -18
  32. package/spec-eval/src/evaltab.ts +4 -3
  33. package/spec-eval/src/scenarios.ts +116 -4
  34. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
  35. package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
  36. package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
  37. package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
  38. package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
  39. package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
  40. /package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +0 -0
  41. /package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +0 -0
  42. /package/spec-cli/templates/spec/project/.plugins/{supervisor → commands/supervisor}/spec.md +0 -0
  43. /package/spec-cli/templates/spec/project/.plugins/{tidy → commands/tidy}/spec.md +0 -0
  44. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
  45. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/spec.md +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -27,8 +27,44 @@ export type Extractor = {
27
27
  export type CodeEntry = { path: string; anchor: string | null }
28
28
  export function parseCodeEntry(raw: string): CodeEntry {
29
29
  const i = raw.indexOf('#')
30
- if (i < 0) return { path: raw, anchor: null }
31
- return { path: raw.slice(0, i), anchor: raw.slice(i + 1).trim() || null }
30
+ if (i < 0) return { path: raw.trim(), anchor: null }
31
+ return { path: raw.slice(0, i).trim(), anchor: raw.slice(i + 1).trim() || null }
32
+ }
33
+
34
+ // ---- relation parsing: ONE structured path+selector grammar for code: AND related: ----
35
+ // A relation's raw rows group per base path: a row is bare (`path`, whole-file — today's semantics,
36
+ // unchanged) or scoped (`path#symbol`), and any number of scoped rows on the SAME base file fold into
37
+ // one entry whose selectors are OR'd (a commit hitting any counts once; no selector-count cap — the
38
+ // benchmark roster's 1–3 was an annotation rubric, never product syntax). STRUCTURAL verdicts live
39
+ // here, pure and loud: an exact duplicate row, mixing bare with selectors on one base path, and a
40
+ // selector on a glob are all `problems` the caller turns into integrity errors. Filesystem/git
41
+ // verdicts (existence, directories, dead/ambiguous units, extractor readiness) stay the caller's —
42
+ // this parser never touches fs.
43
+ export type RelationEntry = { path: string; selectors: string[] }
44
+ export type RelationParse = { entries: RelationEntry[]; problems: string[] }
45
+ export function parseRelation(raws: string[], relation: 'code' | 'related'): RelationParse {
46
+ const order: string[] = []
47
+ const byPath = new Map<string, { bare: boolean; selectors: string[] }>()
48
+ const problems: string[] = []
49
+ for (const raw of raws) {
50
+ const { path, anchor } = parseCodeEntry(raw)
51
+ let e = byPath.get(path)
52
+ if (!e) { e = { bare: false, selectors: [] }; byPath.set(path, e); order.push(path) }
53
+ if (anchor === null) {
54
+ if (e.bare) problems.push(`${relation}: lists '${path}' twice — drop the duplicate entry`)
55
+ e.bare = true
56
+ } else if (e.selectors.includes(anchor)) {
57
+ problems.push(`${relation}: lists selector '${path}#${anchor}' twice — drop the duplicate`)
58
+ } else e.selectors.push(anchor)
59
+ }
60
+ for (const path of order) {
61
+ const e = byPath.get(path)!
62
+ if (e.bare && e.selectors.length)
63
+ problems.push(`${relation}: mixes bare '${path}' with '${path}#…' selectors — one base path is either whole-file or selector-scoped, never both; drop one form`)
64
+ if (e.selectors.length && path.includes('*'))
65
+ problems.push(`${relation}: '${path}#${e.selectors[0]}' puts a selector on a glob — a selector scopes ONE real file`)
66
+ }
67
+ return { entries: order.map((p) => ({ path: p, selectors: byPath.get(p)!.selectors })), problems }
32
68
  }
33
69
 
34
70
  // ---- extractor: ts-ast (the designated extractor for the JS family) ----
@@ -280,21 +316,26 @@ export function windowCommits(idx: DriftIndex, sinceHash: string, path: string):
280
316
  return (idx.fileCommits.get(path) ?? []).filter((h) => !inAncestors(idx, base, h) && !cover.some((a) => inAncestors(idx, a, h)))
281
317
  }
282
318
 
283
- // which window commits TOUCHED the anchored unit: the commit's --unified=0 hunks intersect the unit's
284
- // line range extracted from the file AS IT EXISTED AT THAT COMMIT (never from HEAD — units later renamed
285
- // or moved still attribute correctly). A version whose content the extractor cannot parse is a
286
- // CONSERVATIVE hit (`unparseable` set) over-warn, never silently skip.
287
- export type AnchorHit = { commit: string; unparseable?: string }
288
- export async function anchorHitCommits(root: string, win: string[], path: string, symbol: string, x: Extractor): Promise<AnchorHit[]> {
319
+ // which window commits TOUCHED any of the anchored units: the commit's --unified=0 hunks intersect a
320
+ // unit's line range extracted from the file AS IT EXISTED AT THAT COMMIT (never from HEAD — units later
321
+ // renamed or moved still attribute correctly). Several selectors are OR a commit appears ONCE, with
322
+ // `selectors` naming exactly which units its hunks intersected (so diagnostics can attribute the hit).
323
+ // A version whose content the extractor cannot parse is a CONSERVATIVE hit for every selector
324
+ // (`unparseable` set) over-warn, never silently skip.
325
+ export type AnchorHit = { commit: string; selectors: string[]; unparseable?: string }
326
+ export async function anchorHitCommits(root: string, win: string[], path: string, symbols: string[], x: Extractor): Promise<AnchorHit[]> {
289
327
  const hits: AnchorHit[] = []
290
328
  for (const c of win) {
291
329
  const at = await unitsAt(root, c, path, x)
292
330
  if ('absent' in at) continue // file not in that commit's tree — nothing of the anchor to touch
293
- if ('unparseable' in at) { hits.push({ commit: c, unparseable: at.unparseable }); continue }
294
- const ranges = at.units.filter((u) => u.name === symbol)
295
- if (!ranges.length) continue // unit didn't exist under this name at that commit
331
+ if ('unparseable' in at) { hits.push({ commit: c, selectors: [...symbols], unparseable: at.unparseable }); continue }
332
+ const bySym = symbols
333
+ .map((sym) => ({ sym, ranges: at.units.filter((u) => u.name === sym) }))
334
+ .filter((s) => s.ranges.length) // a unit absent under this name at that commit can't be touched
335
+ if (!bySym.length) continue
296
336
  const hunks = await hunksAt(root, c, path)
297
- if (hunks.some(([a, b]) => ranges.some((u) => a <= u.end && u.start <= b))) hits.push({ commit: c })
337
+ const touched = bySym.filter((s) => hunks.some(([a, b]) => s.ranges.some((u) => a <= u.end && u.start <= b))).map((s) => s.sym)
338
+ if (touched.length) hits.push({ commit: c, selectors: touched })
298
339
  }
299
340
  return hits
300
341
  }
@@ -404,7 +404,11 @@ if (cmd === 'serve') {
404
404
  const owners = specOwners(p)
405
405
  const related = specRelated(p)
406
406
  const maxOwners = loadConfig(process.cwd()).maxOwners
407
- const names = (xs: { id: string }[]) => xs.map((o) => `'${o.id}'`).join(', ')
407
+ // a selector-SCOPED governor (every claiming code: entry carries `#symbol` [[code-anchor]]) still
408
+ // DISPLAYS, marked, but does not count toward the too-many-owners bound: it claims named units, not
409
+ // the whole file.
410
+ const whole = owners.filter((o) => !o.scoped)
411
+ const names = (xs: { id: string; scoped?: boolean }[]) => xs.map((o) => `'${o.id}'${o.scoped ? ' (scoped)' : ''}`).join(', ')
408
412
  const relLine = related.length ? `\n also referenced by ${names(related)} (related: coverage only — no drift, no eval freshness)` : ''
409
413
  if (owners.length === 0 && related.length === 0) {
410
414
  console.log(`${rel} — no spec claims this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
@@ -413,16 +417,16 @@ if (cmd === 'serve') {
413
417
  // but a human asking gets the honest nuance: nothing tracks this file's drift.
414
418
  if (has('actionable')) process.exit(0)
415
419
  console.log(`${rel} — not governed (no code: claim), but referenced by ${names(related)} (related: coverage only). Nothing tracks its drift; if your change is substantive, consider giving it a governing home.`)
416
- } else if (owners.length <= maxOwners) {
420
+ } else if (whole.length <= maxOwners) {
417
421
  // a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
418
422
  // silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
419
423
  if (has('actionable')) process.exit(0)
420
- const named = owners.map((o) => `'${o.id}'`).join(', ')
424
+ const named = names(owners)
421
425
  const lead = owners.length === 1 ? `${rel} is governed by ${named} — ${owners[0].desc}` : `${rel} is governed by ${named} (shared, fine).`
422
426
  console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit.${relLine}`)
423
427
  } else {
424
- const ids = owners.map((o) => o.id).join(', ')
425
- console.log(`${rel} is governed by ${owners.length} specs (${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
428
+ const ids = names(owners)
429
+ console.log(`${rel} is governed whole-file by ${whole.length} specs (all claims: ${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
426
430
  }
427
431
  } else if (sub === 'lint') {
428
432
  const { specLint, DRIFT_GUIDANCE } = await import('./lint.js')
@@ -243,7 +243,14 @@ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, distDi
243
243
  const rel = normalize(decodeURIComponent(urlPath)).replace(/^(\.\.[/\\])+/, '')
244
244
  let file = join(distDir, rel)
245
245
  if (!file.startsWith(distDir)) file = join(distDir, 'index.html')
246
- if (urlPath === '/' || !existsSync(file)) file = join(distDir, 'index.html')
246
+ if (urlPath === '/' || !existsSync(file)) {
247
+ // @@@ missing-asset 404 - only extensionless SPA routes fall back to index.html. A missing FILE
248
+ // request (it has an extension) is a stale hashed chunk from a pre-rebuild page still open in some
249
+ // browser: answering it with HTML trips the strict module-MIME check and hides the miss from the
250
+ // client — 404 instead, so the shell's vite:preloadError recovery can see it and reload.
251
+ if (urlPath !== '/' && extname(file)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found') }
252
+ file = join(distDir, 'index.html')
253
+ }
247
254
  if (!existsSync(file)) { res.writeHead(503); return res.end('dashboard build missing') }
248
255
  const type = MIME[extname(file)] || 'application/octet-stream'
249
256
  const raw = readFileSync(file)
@@ -7,7 +7,7 @@ import { residentForgeState } from '../../spec-forge/src/resident.js'
7
7
  import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
8
8
  import { mergedIssues } from './issues.js'
9
9
  import { evalContext, evalTimeline } from '../../spec-eval/src/evaltab.js'
10
- import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
10
+ import { evalNodesAsync, type ScenarioTestReference } from '../../spec-eval/src/scenarios.js'
11
11
 
12
12
  // a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
13
13
  function resolveParent(path: string, byDir: Record<string, string>): string | null {
@@ -29,11 +29,17 @@ export function latestPerScenario<T extends { scenario: string }>(readings: T[])
29
29
  return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
30
30
  }
31
31
 
32
- // the board's scenario fold ([[graph-lean]]): the declared set rides SLIM — {name, tags} is everything an
33
- // overview surface joins state onto (badge, stats, focus rows, search rows); the prose (description/
34
- // expected) and per-scenario code stay off the hot poll, carried by `/api/specs/lite` and `/api/specs/:id/evals`.
35
- export function slimScenarios(scenarios: { name: string; tags?: string[] }[]): { name: string; tags?: string[] }[] {
36
- return scenarios.map((s) => ({ name: s.name, ...(s.tags?.length ? { tags: s.tags } : {}) }))
32
+ // the board's scenario fold ([[graph-lean]]): the declared set rides SLIM — name/tags plus the normalized
33
+ // test reference a measuring hand can follow. Prose and per-scenario code stay off the hot poll, carried by
34
+ // `/api/specs/lite` and `/api/specs/:id/evals`; the opaque test case name is metadata, not an executor seam.
35
+ export function slimScenarios(
36
+ scenarios: { name: string; tags?: string[]; test?: ScenarioTestReference }[],
37
+ ): { name: string; tags?: string[]; test?: ScenarioTestReference }[] {
38
+ return scenarios.map((s) => ({
39
+ name: s.name,
40
+ ...(s.tags?.length ? { tags: s.tags } : {}),
41
+ ...(s.test ? { test: s.test } : {}),
42
+ }))
37
43
  }
38
44
 
39
45
  export async function buildBoard() {
@@ -139,7 +145,7 @@ export async function buildBoard() {
139
145
  // the LATEST reading per scenario (newest-first), which is all any overview surface consumes (the score
140
146
  // badge, stats, search all reduce to latest-per-scenario anyway); the full timeline stays off the board
141
147
  // and is lazy-loaded by the eval tab from `/api/specs/:id/evals`. `scenarios` (the declared set) rides
142
- // SLIM — {name, tags} only, the fields every overview surface joins state onto — with its prose
148
+ // SLIM — name/tags/test only, the fields every overview surface or measuring hand needs — with its prose
143
149
  // (description/expected) and per-scenario code off the hot poll: they ride the `/api/specs/lite` corpus
144
150
  // (search palette, focus-panel preview) and the `/api/specs/:id/evals` timeline (eval tab).
145
151
  // evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-measurable nodes. The
@@ -62,13 +62,22 @@ FRONTMATTER (YAML between the opening and closing --- lines; every field optiona
62
62
  Drives drift + eval freshness. Many nodes MAY govern the same file (ordinary
63
63
  composition); a file governed by > maxOwners nodes warns (the \`owners\` rule — split it). Omit
64
64
  for a pure-prose node: a cross-cutting contract no file owns.
65
- The entry may pin ONE named unitan ANCHOR: \`path#symbol\` (\`#Class.method\` for a class
66
- method; top-level functions, arrow/const declarations, classes, enums anchor cleanly; a
67
- type/interface anchor warns). An anchor upgrades drift on THAT unit to a blocking error
68
- (\`anchor-drift\`); without one, drift stays advisory forever. Anchors are optional.
65
+ The entry may pin named unitsANCHORS: one or more \`path#symbol\` rows, ALL on the same
66
+ base file (\`#Class.method\` for a class method; top-level functions, arrow/const declarations,
67
+ classes, enums anchor cleanly; a type/interface anchor warns). One-govern counts DISTINCT
68
+ base paths, so selectors never widen govern past one file. Several selectors are OR: a
69
+ commit hitting ANY upgrades drift to the blocking \`anchor-drift\` error (one error per
70
+ entry, naming the hit selectors — a commit counts once). Without an anchor, drift stays
71
+ advisory forever. A base path is either whole-file (bare) or selector-scoped, never both;
72
+ duplicates, globs/directories with a selector, and dead/ambiguous units all error loud. A
73
+ selector-scoped governor claims units, not the file, so it stays out of the \`owners\` bound
74
+ (spex spec owner still displays it, marked "(scoped)"). Anchors are optional.
69
75
  related: files this node REFERENCES but does not own — a YAML list, same path forms. Carries coverage
70
76
  (never drift, never eval freshness, nothing to ack); it is the many-to-many net that claims the files
71
- govern doesn't. Every listed path must exist (lint integrity error otherwise).
77
+ govern doesn't. Every listed path must exist (lint integrity error otherwise). A related row
78
+ may also pin \`path#symbol\`: the node then hears about a commit ONLY when it moves that
79
+ unit — a hit is a soft \`related-drift\` warn naming the selector, a miss is SILENT (a scoped
80
+ related file's ordinary file-level nudge is off). Still never blocks, no ack, no eval freshness.
72
81
  surface plugin-system/.plugins nodes only: one or MORE of system (folded into every agent's prompt) |
73
82
  command (a /command) | skill (an on-demand SKILL.md the harness loads when a task matches the
74
83
  node's desc) | agent (a spawnable sub-agent definition; its \`tools:\` list is the spawned
@@ -92,11 +101,14 @@ WHAT lint CHECKS (spex spec lint; the pre-commit hook gates on errors):
92
101
  deleted/renamed), an ambiguous one (two same-named units in one file), a file that
93
102
  no longer parses, a language with no designated extractor, or an extractor that
94
103
  can't run here (e.g. no host typescript — 'npm i -D typescript' or drop the anchor)
95
- all error, never silently pass.
96
- anchor-drift (error) a commit since the node's version intersected the ANCHORED unit's lines (judged
104
+ all error, never silently pass. So do a relation's STRUCTURAL defects: a duplicate
105
+ entry, a base path both bare and selector-scoped, or a selector on a
106
+ glob/directory.
107
+ anchor-drift (error) a commit since the node's version intersected an ANCHORED unit's lines (judged
97
108
  from the file as it existed AT each commit) and no Spec-OK ack covers it — the
98
- blocking tier of drift. Remedy: update the spec, or \`spex spec ack\` with a real
99
- reason (recorded in the ack commit body).
109
+ blocking tier of drift. Selectors on one file are OR'd: one error per entry, the
110
+ hit selectors named, each commit counted once. Remedy: update the spec, or
111
+ \`spex spec ack\` with a real reason (recorded in the ack commit body).
100
112
  one-govern (error) a node governs (code:) at most ONE file — keep the true subject, move the rest
101
113
  to related:.
102
114
  living (error) no "## vN" changelog headings — the body is current-state.
@@ -111,14 +123,20 @@ WHAT lint CHECKS (spex spec lint; the pre-commit hook gates on errors):
111
123
  coverage (warn) every source file is claimed by ≥1 node — via code: OR related: (related is the net).
112
124
  drift (warn) a governed file has commits newer than the node's spec version — it may be stale.
113
125
  ALWAYS advisory: unanchored drift never blocks a commit (the blocking tier is
114
- anchor-drift above). Remedy: edit the spec to the new intent (re-versions the
115
- node), OR \`spex spec ack <node> --reason "…"\` when only mechanics changed and the
116
- contract still holds.
126
+ anchor-drift above). On a selector-SCOPED code file whose window has NO hit (a
127
+ miss), this advisory stays by default; the committed \`lint.scopedCodeMiss:
128
+ "ignore"\` silences ONLY it (hit blocks, bare drift, integrity, acks, related,
129
+ eval freshness all untouched). Remedy: edit the spec to the new intent
130
+ (re-versions the node), OR \`spex spec ack <node> --reason "…"\` when only
131
+ mechanics changed and the contract still holds.
117
132
  anchor (warn) an anchor pins a type/interface — types reshape with every refactor; anchor the
118
133
  behaviour-bearing unit instead.
119
- related-drift (warn) a related: file moved ahead of the node — a soft nudge, one summary line, never blocks.
120
- owners (warn) a file governed by > maxOwners nodes (default 3) does too much — SPLIT it so each
121
- governor owns its own module (or merge the nodes, or give it one foundation owner).
134
+ related-drift (warn) a related: file moved ahead of the node — a soft nudge, one summary line, never
135
+ blocks. A selector-scoped related row instead warns per HIT (selector named);
136
+ its file-level misses are silent.
137
+ owners (warn) a file governed WHOLE-FILE by > maxOwners nodes (default 3) does too much — SPLIT
138
+ it so each governor owns its own module (or merge the nodes, or give it one
139
+ foundation owner). Selector-scoped governors don't count toward the bound.
122
140
  confusable-id (warn) two leaf ids one edit apart read as the same word — rename one to read apart.
123
141
 
124
142
  LIFECYCLE: author each node on a node/<id> branch, one node per commit; \`spex spec lint\` must reach 0 errors
@@ -141,8 +159,13 @@ FRONTMATTER: a \`scenarios:\` list (a YAML block sequence of mappings). Each sce
141
159
  \`frontend-e2e, backend-api, cli, desktop, mobile\`). A tag outside the library is rejected —
142
160
  use an existing one, or add it to the library to mint it. Tags classify a scenario (surface,
143
161
  device) so it can be filtered and, later, routed to the right driver.
144
- test optional. A repo path to a co-located runnable file (a playwright.spec.ts, a script)
145
- the agent MAY run by hand. Not a driver — eval never executes it.
162
+ test optional. Either a repo-path scalar (the backward-compatible shorthand) or a strict object:
163
+ test:
164
+ path: tests/auth.spec.ts
165
+ name: rejects an expired session
166
+ Both forms normalize in JSON to \`{ "path": "..." }\` with optional \`"name"\`. The object
167
+ requires exactly \`path\` + \`name\`; its case name is opaque text preserved for the measuring
168
+ hand. The path must exist. SpexCode does not parse WDIO/Playwright or execute anything.
146
169
  code optional. The file THIS scenario GOVERNS, ideally one (a comma list / flow list \`[a, b]\` is
147
170
  allowed) — its own slice of the code freshness axis, so scenarios on one node go stale
148
171
  independently. Absent → it inherits the node's \`code:\` list. A file governed by > maxOwners
@@ -354,10 +377,16 @@ the guard (the flag is the declaration of intent). Reads point anywhere.
354
377
  lint.altitude body budgets: { lineBudget, charBudget, sizeable, dense, steps }
355
378
  (defaults 50 / 4200 / 35 / 1.3 / 3).
356
379
  lint.maxChildren breadth budget: warn at >= this many direct children (default 8).
357
- lint.maxOwners warn when a file is governed by > this many nodes (default 3).
380
+ lint.maxOwners warn when a file is governed WHOLE-FILE by > this many nodes (default 3).
381
+ Selector-scoped governors (code: path#symbol) don't count toward the bound.
358
382
  (lint.driftErrorThreshold is RETIRED: the count-based commit gate is replaced
359
383
  by code anchors — \`code: path#symbol\` — whose hits error unconditionally; a
360
384
  leftover key is ignored.)
385
+ lint.scopedCodeMiss "warn" (default) | "ignore" — the file-level drift ADVISORY on a selector-
386
+ scoped code: file whose window commits hit no selector (a miss). "ignore"
387
+ silences ONLY that advisory; it never touches hit blocks (anchor-drift),
388
+ bare-path drift, integrity, Spec-OK acks, related semantics, or eval
389
+ freshness. A project policy → committed spexcode.json.
361
390
  lint.scenarioTags the closed vocabulary an eval scenario's tags: must draw from (default
362
391
  ["frontend-e2e","backend-api","cli","desktop","mobile"]); extend to mint a tag.
363
392
  Example — govern your own source dir and loosen the altitude budget:
@@ -376,9 +405,11 @@ const FOOTPRINT = `spex guide footprint — what SpexCode plants in a repo, and
376
405
 
377
406
  SpexCode claims software engineering's HEAD (the recording of intent) and TAIL (the storage of
378
407
  measurement) and leaves the MIDDLE — construction — to the harness/agent/test framework; freshness
379
- stitches the two ends into a closed loop. The footprint follows: the head+tail (.spec, spexcode.json,
380
- evals) is the ASSET and lives in git like source; everything else is derived wiring or a machine fact.
381
- Materialized artifacts carry no facts, so they are NEVER tracked there is exactly one residence
408
+ stitches the two ends into a closed loop. Materialize is the base operation of harness ADAPTATION:
409
+ one pass renders the spec tree into whatever artifacts the selected harness auto-discovers, so that
410
+ is how SpexCode reaches an agent never a launch-time flag. The footprint follows: the head+tail
411
+ (.spec, spexcode.json, evals) is the ASSET and lives in git like source; everything else is derived
412
+ wiring or a machine fact. Materialized artifacts carry no facts, so they are NEVER tracked — there is exactly one residence
382
413
  behavior, decided per KIND (and, for a contract file, by its live CONTENT).
383
414
 
384
415
  ── THE FOUR KINDS (all fixed) ──
@@ -133,13 +133,21 @@ export interface Harness {
133
133
  // grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
134
134
  liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
135
135
  // deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
136
- // control socket — OPTIMISTIC-after-liveness (loud failure when the socket is missing/dead or the write can't
137
- // flush; once the reply line flushes to a live socket it returns ok, without waiting for an application ack —
138
- // see replyViaSocket). codex: JSON-RPC on the same app-server WebSocket the
136
+ // control socket — an ATOMIC reply+repaint chunk whose `repaint-done` proves the reply was PARSED; a close
137
+ // before it proves a concurrent connect kicked the chunk (the daemon is single-connection) resend; a wall
138
+ // expiry on a still-open connection is a busy-not-lost agent → optimistic ok (see replyViaSocket). codex:
139
+ // JSON-RPC on the same app-server WebSocket the
139
140
  // visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
140
141
  // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
141
142
  // Returns ok=false with a reason that propagates to the API.
142
143
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
144
+ // the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
145
+ // socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
146
+ // the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
147
+ // BEFORE delivering; absent on harnesses with no such state (codex delivery ignores the pane). claude: the
148
+ // TUI's sessions panel ("← for agents") enqueues an injected reply to the panel context and never drains it
149
+ // — verified live: parsed + enqueued, no dequeue, no turn, daemon silent.
150
+ deliveryBlockedBy?(paneText: string): string | null
143
151
  // --- materialize: clean (the inverse of write — [[harness-select]] prunes a deselected harness) ---
144
152
  // clean is the EXACT inverse of materialize's per-harness write: SURGICALLY remove ONLY SpexCode's own
145
153
  // artifacts — the managed contract block (sentinels), the generated shim file, the trust block, and the
@@ -161,11 +169,11 @@ export interface Harness {
161
169
  }
162
170
 
163
171
  // a prompt-dispatch outcome. ok=true means delivery is confirmed at the layer that harness proves it: claude at
164
- // the TRANSPORT/liveness layer (the reply line flushed to a live rendezvous socket see replyViaSocket for why
165
- // it is OPTIMISTIC-after-liveness, not a round-trip ack); codex at the application layer (the app-server accepted
166
- // `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route (non-2xx)
167
- // and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts re-exports it for
168
- // its existing importers.
172
+ // the DAEMON-PARSE layer (the atomic reply+repaint chunk answered `repaint-done`, or the wall expired on a
173
+ // still-open connection — busy, not lost; see replyViaSocket); codex at the application layer (the app-server
174
+ // accepted `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
175
+ // (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
176
+ // re-exports it for its existing importers.
169
177
  export type DispatchResult = { ok: boolean; error?: string }
170
178
  export type HarnessDeliveryRecord = { session: string; worktreePath?: string; harnessSessionId?: string | null; runtimeDir?: string }
171
179
  // the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
@@ -252,50 +260,84 @@ function shQuote(s: string): string {
252
260
  const PKG = fileURLToPath(new URL('..', import.meta.url))
253
261
  const SPEX = join(PKG, 'bin', 'spex.mjs')
254
262
 
255
- // @@@ replyViaSocket - OPTIMISTIC-after-liveness delivery: connect to the LIVE rendezvous socket and WRITE the
256
- // `{type:reply}\n` line; once that line FLUSHES to the socket with no immediate transport error, the reply is on
257
- // the wire and claude submits it the moment it yields the event loop so we return ok:true right there. We do
258
- // NOT wait for an application-level acceptance ack. The old code wrote a `{type:repaint}` probe after the reply
259
- // and waited up to 2500ms for a `{type:repaint-done}` to CONFIRM the reply was processed; the ordering barrier is
260
- // sound one-way (repaint-done delivered) but its ABSENCE is NOT proof of non-delivery a mid-turn/BUSY claude
261
- // can't answer the probe within the wall, so a message that actually landed reported a COMMON, misleading false
262
- // failure. The delivery confirmation therefore lives at the TRANSPORT/liveness layer, not the application layer:
263
- // deliverViaRendezvous already gates on the socket's existence (liveness), and here the FREE, INSTANT transport
264
- // signals a connect that throws, a socket 'error' (ECONNREFUSED / EPIPE), or a 'close' BEFORE the write flushes —
265
- // are still reported as real failures (they cost no waiting). What we knowingly give up is detection of the narrow
266
- // alive-but-wedged / reply-rejected / shutting-down cases: a rare silent drop, recoverable because the supervisor
267
- // sees the worker never transition. That tradeoff is the deliberate contract now see [[harness-adapter]].
268
- // Never throws.
269
- function replyViaSocket(sock: string, text: string): Promise<DispatchResult> {
263
+ // @@@ replyViaSocket - ATOMIC parse-confirmed delivery. The daemon is SINGLE-CONNECTION: a new connect
264
+ // `destroy()`s the previous socket, discarding any received-but-not-yet-parsed line with it and our own
265
+ // `rendezvousListening` liveness probe IS such a connect, fired for every session on every board snapshot. So
266
+ // the previous optimistic write (return ok once the reply line flushed) LOST prompts whenever a probe landed in
267
+ // the write→parse window, a window that widens exactly when claude is busy mid-turn (field: dashboard messages
268
+ // recorded `sent` with no trace in the claude transcript; measured 2/10 lost under a 20ms probe hammer). The
269
+ // daemon parses a chunk's complete lines in ONE synchronous loop, so writing `{type:reply}` + `{type:repaint}`
270
+ // as ONE chunk makes the pair indivisible a kick loses BOTH or NEITHER — and the outcome decidable from this
271
+ // connection alone:
272
+ // `repaint-done` arrives → the reply line before it was parsed (in-order barrier) ok, CONFIRMED.
273
+ // 'close' before it → the chunk was never parsed (kicked by a concurrent connect) resolve kicked:true
274
+ // so deliverViaRendezvous RESENDS a proven loss, so the retry cannot duplicate.
275
+ // wall expires, conn open a busy event loop is DELAYING, not losing (the 2500ms-wall lesson: ack absence is
276
+ // NOT non-delivery) → ok, OPTIMISTIC — never a false failure on a busy worker.
277
+ // `reply-rejected`/`auth-rejected`/`shutting-down` loud failure, not retried.
278
+ // Other daemon lines (heartbeat, state patches) are ignored. Never throws.
279
+ type ReplyOutcome = DispatchResult & { kicked?: boolean }
280
+ function replyViaSocket(sock: string, text: string, wallMs = 10_000): Promise<ReplyOutcome> {
270
281
  return new Promise((resolve) => {
271
282
  let settled = false
272
283
  let c: ReturnType<typeof createConnection>
273
- const done = (r: DispatchResult) => {
284
+ const done = (r: ReplyOutcome) => {
274
285
  if (settled) return
275
286
  settled = true
287
+ clearTimeout(wall)
276
288
  try { c?.destroy() } catch { /* */ }
277
289
  resolve(r)
278
290
  }
291
+ const wall = setTimeout(() => done({ ok: true }), wallMs)
279
292
  try {
280
293
  c = createConnection({ path: sock })
281
294
  } catch (e) {
282
295
  done({ ok: false, error: `rendezvous socket connect threw: ${String(e)}` })
283
296
  return
284
297
  }
285
- c.on('error', (e: NodeJS.ErrnoException) => done({ ok: false, error: `rendezvous socket connect failed: ${e?.code || String(e)}` }))
286
- c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the prompt was written' }))
287
- // write the reply line; the write callback fires once it flushes to the live socket optimistic ok. An
288
- // immediate transport 'error'/'close' beats the callback and reports the real failure (done is idempotent).
289
- c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n', () => done({ ok: true })))
298
+ // ECONNRESET/EPIPE are the KICK surfacing as an error: the daemon destroy()s the previous connection the
299
+ // moment a new one connects, and destroying a socket with OUR chunk still unread raises RST — whereas a
300
+ // parsed chunk answers repaint-done (readable even after a later close) before any clean FIN. So both codes
301
+ // PROVE the chunk was never parsed retryable, same as the clean pre-parse close. ECONNREFUSED/ENOENT
302
+ // (daemon gone) stay loud.
303
+ c.on('error', (e: NodeJS.ErrnoException) => {
304
+ const code = e?.code || String(e)
305
+ const kicked = code === 'ECONNRESET' || code === 'EPIPE'
306
+ done({ ok: false, ...(kicked ? { kicked } : {}), error: `rendezvous socket error: ${code} — prompt NOT delivered` })
307
+ })
308
+ c.on('close', () => done({ ok: false, kicked: true, error: 'rendezvous connection was closed before the daemon parsed the prompt (kicked by a concurrent connect)' }))
309
+ c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n' + JSON.stringify({ type: 'repaint' }) + '\n'))
310
+ let buf = ''
311
+ c.on('data', (d) => {
312
+ buf += d.toString('utf8')
313
+ let nl
314
+ while ((nl = buf.indexOf('\n')) >= 0) {
315
+ const line = buf.slice(0, nl)
316
+ buf = buf.slice(nl + 1)
317
+ let type = ''
318
+ try { type = (JSON.parse(line) as { type?: string })?.type ?? '' } catch { continue }
319
+ if (type === 'repaint-done') return done({ ok: true })
320
+ if (type === 'reply-rejected' || type === 'auth-rejected') return done({ ok: false, error: `rendezvous daemon rejected the prompt (${type}) — prompt NOT delivered` })
321
+ if (type === 'shutting-down') return done({ ok: false, error: 'agent is shutting down — prompt NOT delivered' })
322
+ }
323
+ })
290
324
  })
291
325
  }
292
326
  // claude's deliver: the pre-write LIVENESS gate — fail loud BEFORE attempting the socket if it isn't there (a
293
- // clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent alive).
294
- // Then inject the reply optimistically via replyViaSocket (no round-trip ack).
295
- function deliverViaRendezvous(id: string, text: string): Promise<DispatchResult> {
327
+ // clearer message than a raw connect error, and the delivery's confirmation layer: socket present = agent
328
+ // alive). Then the atomic parse-confirmed write; a KICKED outcome is a proven whole-chunk loss, so it resends
329
+ // (bounded attempts + jitter so re-collision with the probe cadence is unlikely); exhausted retries fail loud.
330
+ const DELIVER_ATTEMPTS = 3
331
+ export async function deliverViaRendezvous(id: string, text: string, wallMs?: number): Promise<DispatchResult> {
296
332
  const sock = rvSock(id)
297
- if (!existsSync(sock)) return Promise.resolve({ ok: false, error: `no rendezvous control socket for session ${id} (socketless/old session, or the agent is offline) — prompt NOT delivered` })
298
- return replyViaSocket(sock, text)
333
+ if (!existsSync(sock)) return { ok: false, error: `no rendezvous control socket for session ${id} (socketless/old session, or the agent is offline) — prompt NOT delivered` }
334
+ let last: ReplyOutcome = { ok: false, error: 'not attempted' }
335
+ for (let attempt = 1; attempt <= DELIVER_ATTEMPTS; attempt++) {
336
+ last = await replyViaSocket(sock, text, wallMs)
337
+ if (last.ok || !last.kicked) return { ok: last.ok, ...(last.error ? { error: last.error } : {}) }
338
+ await new Promise((r) => setTimeout(r, 60 + Math.random() * 140))
339
+ }
340
+ return { ok: false, error: `rendezvous delivery was kicked by concurrent connects ${DELIVER_ATTEMPTS}× — prompt NOT delivered, retry the send` }
299
341
  }
300
342
 
301
343
  type JsonRpc = { id?: number; method?: string; params?: unknown; result?: unknown; error?: { code?: number; message?: string } }
@@ -997,6 +1039,15 @@ export const claudeHarness: Harness = {
997
1039
  // dead-pane-reads-working bug). See rendezvousListening.
998
1040
  liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
999
1041
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1042
+ // the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
1043
+ // and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
1044
+ // the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
1045
+ // strings — the new-session composer placeholder, or its footer key hints together (either alone could drift
1046
+ // across claude versions; requiring the footer PAIR keeps a prose false-positive unlikely).
1047
+ deliveryBlockedBy: (paneText) =>
1048
+ paneText.includes('describe a task for a new session') || (paneText.includes('enter to return') && paneText.includes('space to reply'))
1049
+ ? 'the claude TUI is focused on its sessions panel ("← for agents"), which silently swallows injected prompts — press Enter in the session terminal to return to the composer, then resend'
1050
+ : null,
1000
1051
  resumeArg: (rec) => `--resume ${rec.session}`,
1001
1052
  }
1002
1053
 
@@ -55,15 +55,17 @@ a tracked/mixed CLAUDE.md/AGENTS.md covered by the clean/smudge filter (see spex
55
55
  see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex spec lint (adoption TODO)',
56
56
  },
57
57
  materialize: {
58
- line: 'materialize re-materialize the harness artifacts (contract block · shims) for cwd’s project',
58
+ line: 'materialize the base pass of harness adaptation: render .spec/.plugins into your harness’s artifacts',
59
59
  body: `Usage: spex materialize
60
60
 
61
- Materializes the surface:system plugin nodes into the managed <!-- spexcode --> block of
62
- CLAUDE.md/AGENTS.md plus the .claude/.codex shims, and prints the content hash. The materialize anchors on
63
- git-native events only (init · this verb · session-worktree creation · the pre-commit/post-checkout/
64
- post-merge hooks) run it by hand after a toolchain update, or in the setup step of any clone that
65
- has no spex-planted hooks yet (CI, a cloud agent): the artifacts are generated and excluded, so they
66
- never arrive via git.`,
61
+ The base operation of HARNESS ADAPTATION: one pass renders the spec tree's surface nodes into the
62
+ artifacts each selected harness auto-discovers the managed <!-- spexcode --> block of
63
+ CLAUDE.md/AGENTS.md, the .claude/.codex shims, the skills/agents and prints the content hash.
64
+ The outputs are derived and never tracked: to change one, edit its source (.plugins, spexcode.json)
65
+ and re-materialize never the artifact. Not a one-time setup: it anchors on git-native events
66
+ (init · this verb · session-worktree creation · the pre-commit/post-checkout/post-merge hooks) —
67
+ run it by hand after a toolchain update, or in the setup step of any clone that has no spex-planted
68
+ hooks yet (CI, a cloud agent): generated and excluded, the artifacts never arrive via git.`,
67
69
  see: 'spex doctor (verify the materialized artifacts actually reach an agent)',
68
70
  },
69
71
  doctor: {
@@ -122,14 +124,18 @@ owner — the reverse edge: a file's GOVERNORS (code: — drives drift + eval fr
122
124
  over-owned → split the file). --actionable prints NOTHING unless action is needed (hook use).
123
125
 
124
126
  lint — checks the whole spec↔code graph and exits non-zero on errors. Errors: integrity (a
125
- code:/related: file does not exist; a dead/ambiguous/unverifiable \`path#symbol\` anchor; an anchor
126
- whose language has no designated extractor or whose extractor can't run here) · anchor-drift (a
127
- commit since the spec's version touched the ANCHORED unit's lines, unacked the blocking tier of
128
- drift) · one-govern (a node governs >1 file) · living (a "## vN" changelog heading) · id-format (an
127
+ code:/related: file does not exist; a dead/ambiguous/unverifiable \`path#symbol\` selector; a selector
128
+ whose language has no designated extractor or whose extractor can't run here; a duplicate entry, a
129
+ base path both bare and scoped, or a selector on a glob/directory) · anchor-drift (a
130
+ commit since the spec's version touched an ANCHORED unit's lines, unacked the blocking tier of
131
+ drift; same-file selectors OR'd, one error naming the hit selectors) · one-govern (a node
132
+ governs >1 DISTINCT file) · living (a "## vN" changelog heading) · id-format (an
129
133
  id char outside the whitelist — ascii [a-z0-9-] or a non-ascii unicode letter/number, CJK ok — or a
130
134
  leaf id reused) · mention (a [[id]] naming no node). Warns: altitude · breadth · coverage · drift
131
- (UNANCHORED drift — always advisory, never blocks) · anchor (anchoring a type) · related-drift ·
132
- owners · confusable-id (two leaf ids one edit apart). spec lint's errors BLOCK commits (the pre-commit shim; bypass SPEXCODE_SKIP_LINT=1);
135
+ (UNANCHORED drift — always advisory, never blocks; on a scoped file's MISS, \`lint.scopedCodeMiss:
136
+ "ignore"\` may silence it) · anchor (anchoring a type) · related-drift (a scoped related row warns
137
+ per selector HIT, misses silent) · owners (whole-file governors only; scoped don't count) ·
138
+ confusable-id (two leaf ids one edit apart). spec lint's errors BLOCK commits (the pre-commit shim; bypass SPEXCODE_SKIP_LINT=1);
133
139
  contrast \`spex eval lint\`, which is pure advisory and never blocks anyone.
134
140
 
135
141
  ack — stamp Spec-OK on HEAD (an empty stamp commit): the drift remedy when only MECHANICS changed
@@ -218,7 +224,7 @@ ls — node-scoped bare (its per-scenario eval history); session-scoped with an
218
224
  ✦-marked ahead of the inherited baseline. --export writes that evaluation as ONE self-contained
219
225
  HTML artifact (diff · evidence inlined · gates) for CI/sharing.
220
226
 
221
- scenario ls — the DECLARED contracts (name · tags · latest verdict), no evals: bare lists every
227
+ scenario ls — the DECLARED contracts (name · tags · normalized test reference · latest verdict), no evals: bare lists every
222
228
  measurable node's scenarios; --unmeasured keeps only the never-measured — the blind-spot worklist.
223
229
 
224
230
  lint — the measurement layer's findings: malformed eval.md (eval-schema) · unmeasured (eval-missing) ·