spexcode 0.3.0 → 0.4.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 (39) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/anchors.ts +300 -0
  3. package/spec-cli/src/cli.ts +11 -15
  4. package/spec-cli/src/git.ts +21 -13
  5. package/spec-cli/src/guide.ts +22 -5
  6. package/spec-cli/src/help.ts +19 -9
  7. package/spec-cli/src/index.ts +14 -0
  8. package/spec-cli/src/init.ts +5 -4
  9. package/spec-cli/src/lint.ts +55 -22
  10. package/spec-cli/src/localIssues.ts +19 -0
  11. package/spec-cli/src/migrate-table.ts +13 -12
  12. package/spec-cli/src/search.bench.mjs +2 -2
  13. package/spec-cli/src/specs.ts +19 -14
  14. package/spec-cli/templates/spec/project/.plugins/core/spec.md +1 -1
  15. package/spec-cli/templates/spec/project/.plugins/extract/spec.md +1 -1
  16. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  17. package/spec-cli/templates/spec/project/.plugins/spec.md +5 -2
  18. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +1 -1
  19. package/spec-cli/templates/spec/project/spec.md +1 -1
  20. package/spec-dashboard/dist/assets/{Dashboard-C7Bzsv86.js → Dashboard-CTcH2eW9.js} +3 -3
  21. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
  22. package/spec-dashboard/dist/assets/{FoldToggle-D5iB4Ac2.js → FoldToggle-CVFbBpyW.js} +1 -1
  23. package/spec-dashboard/dist/assets/{IssuesPage-CMFTsQhg.js → IssuesPage-kULjonqj.js} +1 -1
  24. package/spec-dashboard/dist/assets/{MobileApp-DwuTKgdP.js → MobileApp-B0ZJju8K.js} +1 -1
  25. package/spec-dashboard/dist/assets/{SessionInterface-CBS5_cmK.js → SessionInterface-BRKJqU2U.js} +1 -1
  26. package/spec-dashboard/dist/assets/{SessionWindow-CqAnjWfI.js → SessionWindow-CDhEL7wO.js} +7 -7
  27. package/spec-dashboard/dist/assets/{Settings-BW5f0OaW.js → Settings-BL6FV_8S.js} +1 -1
  28. package/spec-dashboard/dist/assets/{index-Cc26X4ce.css → index-DmQsNYKK.css} +1 -1
  29. package/spec-dashboard/dist/assets/{index-Ce0wDyQS.js → index-DulGPk6A.js} +7 -7
  30. package/spec-dashboard/dist/index.html +2 -2
  31. package/spec-eval/src/cli.ts +50 -3
  32. package/spec-eval/src/evaltab.ts +11 -3
  33. package/spec-eval/src/humanok.ts +43 -0
  34. package/spec-eval/src/sidecar.ts +35 -9
  35. package/spec-cli/templates/presets/careful/.plugins/clarify-before-code/spec.md +0 -11
  36. package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +0 -2
  37. /package/spec-cli/templates/spec/project/.plugins/{forge-link → prompts/forge-link}/spec.md +0 -0
  38. /package/spec-cli/templates/spec/project/.plugins/{memory-hygiene → prompts/memory-hygiene}/spec.md +0 -0
  39. /package/spec-cli/templates/spec/project/.plugins/{reproduce-before-fix → prompts/reproduce-before-fix}/spec.md +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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",
@@ -0,0 +1,300 @@
1
+ import { join } from 'node:path'
2
+ import { createRequire } from 'node:module'
3
+ import { gitA, type DriftIndex, ancestorsOf, inAncestors, ackCoverFor } from './git.js'
4
+
5
+ // ---- the anchor vocabulary ([[code-anchor]]) ----
6
+ // A spec's `code:` entry may pin ONE named unit: `path#symbol` (`#Class.method` for a class method).
7
+ // Everything below the entry parse splits into two layers:
8
+ // - the LANGUAGE SEAM: pure extractors (content, filename) -> Unit[] — no git, no cache, no fs.
9
+ // Each extension maps to exactly ONE designated extractor; there is NO cross-tier fallback.
10
+ // - the LANGUAGE-AGNOSTIC ENGINE: blob-oid memo, anchor resolution (dead/ambiguous), diff-hunk ∩
11
+ // unit-range intersection over the drift window. It never knows which language it is measuring.
12
+
13
+ export type Unit = { name: string; kind: string; start: number; end: number; typeOnly?: boolean }
14
+
15
+ export type Extractor = {
16
+ id: string
17
+ claims(ext: string): boolean
18
+ // true = usable here. A string is WHY it cannot run — lint turns that into an ERROR with the repair
19
+ // entrypoint (never a silent or degraded pass): the designated extractor either runs or the anchor
20
+ // is unverifiable.
21
+ ready(): true | string
22
+ // PURE function of its arguments (importable by an external benchmark/scorer as-is). Throws when the
23
+ // content cannot be parsed — the caller maps that to a conservative verdict, never a silent skip.
24
+ extract(content: string, filename: string): Unit[]
25
+ }
26
+
27
+ export type CodeEntry = { path: string; anchor: string | null }
28
+ export function parseCodeEntry(raw: string): CodeEntry {
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 }
32
+ }
33
+
34
+ // ---- extractor: ts-ast (the designated extractor for the JS family) ----
35
+ // Parse-only via the HOST project's own typescript (resolved from the repo root, walking up like any
36
+ // require) — never a bundled copy, so the parse matches what the project itself compiles with. Not
37
+ // resolvable => ready() returns the repair entrypoint and lint ERRORS (no regex fallback for JS).
38
+ const JS_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts'])
39
+
40
+ export function tsAstExtractor(root: string): Extractor {
41
+ let ts: any | null | undefined // undefined = unprobed; null = unresolvable
42
+ let readiness: true | string | undefined
43
+ const probe = () => {
44
+ if (ts !== undefined) return
45
+ try { ts = createRequire(join(root, 'package.json'))('typescript') } catch { ts = null }
46
+ }
47
+ return {
48
+ id: 'ts-ast',
49
+ claims: (ext) => JS_EXTS.has(ext),
50
+ ready() {
51
+ if (readiness !== undefined) return readiness
52
+ probe()
53
+ if (!ts) return (readiness = `typescript is not resolvable from ${root} — anchors on JS-family files need the host project's typescript: run 'npm i -D typescript', or remove the #anchor`)
54
+ // resolvability is not usability: typescript@7 (the Go rewrite) may resolve yet not expose the JS
55
+ // compiler API this extractor drives. Probe the ACTUAL surface with a tiny parse — an incompatible
56
+ // host typescript is a loud error, never a silent pass or downgrade.
57
+ try {
58
+ const sf = ts.createSourceFile('probe.ts', 'const x = 1', ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
59
+ if (!sf?.statements?.length || sf.parseDiagnostics?.length) throw new Error('probe parse failed')
60
+ readiness = true
61
+ } catch {
62
+ readiness = `host typescript (v${ts?.version ?? 'unknown'}) resolves but its createSourceFile API is unusable (a TS7/Go build?) — pin 'npm i -D typescript@5', or remove the #anchor`
63
+ }
64
+ return readiness
65
+ },
66
+ extract(content, filename) {
67
+ probe()
68
+ if (!ts) throw new Error('ts-ast extractor is not ready (typescript unresolvable)')
69
+ const kind = /\.(tsx)$/.test(filename) ? ts.ScriptKind.TSX
70
+ : /\.(jsx)$/.test(filename) ? ts.ScriptKind.JSX
71
+ : /\.(ts|mts|cts)$/.test(filename) ? ts.ScriptKind.TS
72
+ : ts.ScriptKind.JS
73
+ const sf = ts.createSourceFile(filename, content, ts.ScriptTarget.Latest, true, kind)
74
+ // parse-only gate: a file that does not parse yields GARBAGE units (a shell script's `x=$(...)`
75
+ // parses as a const) — throw so the caller renders an honest "cannot parse" verdict instead.
76
+ if (sf.parseDiagnostics?.length) throw new Error(`${filename} does not parse as ${ts.ScriptKind[kind]} (${sf.parseDiagnostics.length} syntax error(s))`)
77
+ const line = (pos: number) => sf.getLineAndCharacterOfPosition(pos).line + 1
78
+ const units: Unit[] = []
79
+ const push = (name: string, ukind: string, node: any, typeOnly = false) =>
80
+ units.push({ name, kind: ukind, start: line(node.getStart(sf)), end: line(node.end), ...(typeOnly ? { typeOnly } : {}) })
81
+ for (const st of sf.statements) {
82
+ if (ts.isFunctionDeclaration(st)) push(st.name ? st.name.text : '(default)', 'function', st)
83
+ else if (ts.isClassDeclaration(st)) {
84
+ const cname = st.name ? st.name.text : '(default)'
85
+ push(cname, 'class', st)
86
+ for (const m of st.members) {
87
+ if ((ts.isMethodDeclaration(m) || ts.isConstructorDeclaration(m) || ts.isGetAccessorDeclaration(m) || ts.isSetAccessorDeclaration(m)) && m.body) {
88
+ const mname = ts.isConstructorDeclaration(m) ? 'constructor' : (m.name && ts.isIdentifier(m.name) ? m.name.text : '(computed)')
89
+ push(`${cname}.${mname}`, 'method', m)
90
+ }
91
+ }
92
+ } else if (ts.isVariableStatement(st)) {
93
+ for (const d of st.declarationList.declarations) {
94
+ if (!ts.isIdentifier(d.name)) continue // destructuring — not anchorable by one name
95
+ const fn = d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))
96
+ // range = the whole statement (multi-declarator lines co-move; each name shares the range)
97
+ units.push({ name: d.name.text, kind: fn ? 'const-fn' : 'const-data', start: line(st.getStart(sf)), end: line(st.end) })
98
+ }
99
+ } else if (ts.isEnumDeclaration(st)) push(st.name.text, 'enum', st)
100
+ else if (ts.isInterfaceDeclaration(st)) push(st.name.text, 'interface', st, true)
101
+ else if (ts.isTypeAliasDeclaration(st)) push(st.name.text, 'type', st, true)
102
+ }
103
+ return units
104
+ },
105
+ }
106
+ }
107
+
108
+ // ---- extractor: heuristic(langSpec) — a generic regex engine fed LANGUAGE DATA, not language branches ----
109
+ // The designated extractor for languages described by a LangSpec row; adding a language = adding a data
110
+ // row + a registry entry (never a new engine). The JS family is deliberately NOT routed here (its
111
+ // designated extractor is ts-ast above); JS_LANG_R5B below exists as the validated reference row for the
112
+ // engine's shape and for the external benchmark to score.
113
+ export type LangSpec = {
114
+ id: string
115
+ extensions: string[]
116
+ // column-0 declaration patterns; capture group 1 = the unit name (or the declarator list when declList)
117
+ decls: { re: RegExp; kind: string; typeOnly?: boolean; classOpener?: boolean; declList?: boolean }[]
118
+ // class-member pattern, active while inside a classOpener's balanced-bracket body (name -> Class.name)
119
+ member?: { re: RegExp; blacklist?: RegExp }
120
+ // a column-0 line matching this ENDS the previous unit (comment-aware so trailing comment blocks
121
+ // attach to the NEXT unit, not the previous one)
122
+ boundary: RegExp
123
+ }
124
+
125
+ const balance = (s: string) => { let n = 0; for (const ch of s) { if ('([{'.includes(ch)) n++; else if (')]}'.includes(ch)) n-- } return n }
126
+ // split a declarator-list head on top-level commas: `COLS = 220, ROWS = 50` -> [COLS, ROWS]
127
+ function declNames(head: string): string[] {
128
+ let d = 0, seg = ''
129
+ const segs: string[] = []
130
+ for (const ch of head) {
131
+ if ('([{<'.includes(ch)) d++
132
+ else if (')]}>'.includes(ch)) d--
133
+ if (ch === ',' && d === 0) { segs.push(seg); seg = '' } else seg += ch
134
+ }
135
+ segs.push(seg)
136
+ return segs.map((s) => s.match(/^\s*([A-Za-z_$][\w$]*)\s*(?::|=|$)/)?.[1]).filter((x): x is string => !!x)
137
+ }
138
+
139
+ export function heuristicExtractor(spec: LangSpec): Extractor {
140
+ return {
141
+ id: spec.id,
142
+ claims: (ext) => spec.extensions.includes(ext),
143
+ ready: () => true,
144
+ extract(content) {
145
+ const lines = content.split('\n')
146
+ const units: Unit[] = []
147
+ let cls: string | null = null, depth = 0
148
+ for (let i = 0; i < lines.length; i++) {
149
+ const l = lines[i]
150
+ if (cls) {
151
+ const m = spec.member && l.match(spec.member.re)
152
+ if (m && !spec.member!.blacklist?.test(m[1])) units.push({ name: `${cls}.${m[1]}`, kind: 'method', start: i + 1, end: i + 1 })
153
+ depth += balance(l)
154
+ if (depth <= 0) cls = null
155
+ continue
156
+ }
157
+ for (const d of spec.decls) {
158
+ const m = l.match(d.re)
159
+ if (!m) continue
160
+ for (const name of d.declList ? declNames(m[1]) : [m[1]])
161
+ units.push({ name, kind: d.kind, start: i + 1, end: i + 1, ...(d.typeOnly ? { typeOnly: true } : {}) })
162
+ if (d.classOpener) { cls = m[1]; depth = balance(l) }
163
+ break
164
+ }
165
+ }
166
+ // R5b ranges: a unit ends before the next column-0 boundary line; a method is also capped by the
167
+ // next unit's start (methods sit inside their class's indentation, below boundary's radar).
168
+ const bset: number[] = []
169
+ for (let i = 0; i < lines.length; i++) if (spec.boundary.test(lines[i])) bset.push(i + 1)
170
+ const starts = units.map((u) => u.start).sort((a, b) => a - b)
171
+ for (const u of units) {
172
+ const nb = bset.find((b) => b > u.start)
173
+ let end = nb ?? lines.length + 1
174
+ if (u.kind === 'method') { const ns = starts.find((x) => x > u.start); if (ns && ns < end) end = ns }
175
+ u.end = Math.max(u.start, end - 1)
176
+ }
177
+ return units
178
+ },
179
+ }
180
+ }
181
+
182
+ // The validated JS-family reference row (R5b: name precision 99.7% / recall 100% / range 98.9% on the
183
+ // 41-file oracle) — NOT registered for JS (ts-ast is designated); kept as the engine's reference shape
184
+ // and the benchmark's scoring subject.
185
+ export const JS_LANG_R5B: LangSpec = {
186
+ id: 'heuristic-js',
187
+ extensions: [...JS_EXTS],
188
+ decls: [
189
+ { re: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\*?\s+([A-Za-z_$][\w$]*)/, kind: 'function' },
190
+ { re: /^(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, kind: 'class', classOpener: true },
191
+ { re: /^(?:export\s+)?(?:declare\s+)?enum\s+([A-Za-z_$][\w$]*)/, kind: 'enum' },
192
+ { re: /^(?:export\s+)?(?:declare\s+)?interface\s+([A-Za-z_$][\w$]*)/, kind: 'interface', typeOnly: true },
193
+ { re: /^(?:export\s+)?(?:declare\s+)?type\s+([A-Za-z_$][\w$]*)/, kind: 'type', typeOnly: true },
194
+ { re: /^(?:export\s+)?(?:const|let|var)\s+(.+)$/, kind: 'const', declList: true },
195
+ ],
196
+ member: {
197
+ re: /^\s+(?:(?:public|private|protected|static|readonly|async|get|set)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/,
198
+ blacklist: /^(if|for|while|switch|return|catch|new|await|typeof|throw|else|do)$/,
199
+ },
200
+ boundary: /^(?:[A-Za-z_$]|\/\/|\/\*)/,
201
+ }
202
+
203
+ // ---- registry: extension -> its ONE designated extractor ----
204
+ // The registry's shape is the Extractor INTERFACE, not any engine: a future language row may be a
205
+ // heuristicExtractor(LangSpec) or a web-tree-sitter extractor carrying its own wasm-grammar/query
206
+ // config — whatever the implementation needs rides inside its own factory, never in the registry.
207
+ export function extractors(root: string): Extractor[] {
208
+ return [tsAstExtractor(root)]
209
+ }
210
+ // first claiming extractor IS the designation (the registry order defines it); null = no anchor support
211
+ // for this language yet (lint ERRORS — the remedy is a LangSpec data row, or dropping the anchor).
212
+ export function extractorFor(regs: Extractor[], ext: string): Extractor | null {
213
+ return regs.find((x) => x.claims(ext)) ?? null
214
+ }
215
+ export const extOf = (path: string): string => {
216
+ const base = path.slice(path.lastIndexOf('/') + 1)
217
+ const dot = base.lastIndexOf('.')
218
+ return dot > 0 ? base.slice(dot + 1) : ''
219
+ }
220
+
221
+ // ---- anchor resolution (language-agnostic) ----
222
+ export type AnchorResolution = { ok: Unit } | { dead: true } | { ambiguous: number }
223
+ export function resolveAnchor(units: Unit[], symbol: string): AnchorResolution {
224
+ const hits = units.filter((u) => u.name === symbol)
225
+ if (!hits.length) return { dead: true }
226
+ if (hits.length > 1) return { ambiguous: hits.length }
227
+ return { ok: hits[0] }
228
+ }
229
+
230
+ // ---- the historical hit engine (language-agnostic; batch short-lived git, no resident process) ----
231
+
232
+ // units of a file AS OF a commit, memoized by (blob oid, extractor id) — a blob is immutable, so the
233
+ // memo never invalidates; distinct file versions in a window are few. 'absent' = no blob at that commit;
234
+ // 'unparseable' = the extractor rejected that version's content (the caller treats it conservatively).
235
+ type BlobUnits = { units: Unit[] } | { absent: true } | { unparseable: string }
236
+ const unitMemo = new Map<string, BlobUnits>()
237
+ const MEMO_MAX = 4096
238
+ async function unitsAt(root: string, commit: string, path: string, x: Extractor): Promise<BlobUnits> {
239
+ const oid = (await gitA(['-C', root, 'rev-parse', `${commit}:${path}`])).trim()
240
+ if (!oid) return { absent: true }
241
+ const key = `${oid}\0${x.id}`
242
+ const hit = unitMemo.get(key)
243
+ if (hit) return hit
244
+ const text = await gitA(['-C', root, 'cat-file', 'blob', oid])
245
+ let v: BlobUnits
246
+ try { v = { units: x.extract(text, path) } } catch (e: any) { v = { unparseable: e?.message ?? String(e) } }
247
+ if (unitMemo.size >= MEMO_MAX) unitMemo.clear()
248
+ unitMemo.set(key, v)
249
+ return v
250
+ }
251
+
252
+ // post-image line ranges of one commit's diff to one file (`@@ -a,b +c,d @@`, --unified=0). d>0 → lines
253
+ // c..c+d-1 changed; d==0 (pure deletion) → the point line after which content vanished. Immutable per
254
+ // (commit, file), memoized.
255
+ const hunkMemo = new Map<string, [number, number][]>()
256
+ async function hunksAt(root: string, commit: string, path: string): Promise<[number, number][]> {
257
+ const key = `${commit}\0${path}`
258
+ const hit = hunkMemo.get(key)
259
+ if (hit) return hit
260
+ const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '--unified=0', '--format=', commit, '--', path])
261
+ const ranges: [number, number][] = []
262
+ for (const m of out.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
263
+ const c = +m[1], d = m[2] === undefined ? 1 : +m[2]
264
+ ranges.push(d > 0 ? [c, c + d - 1] : [Math.max(1, c), Math.max(1, c)])
265
+ }
266
+ if (hunkMemo.size >= MEMO_MAX) hunkMemo.clear()
267
+ hunkMemo.set(key, ranges)
268
+ return ranges
269
+ }
270
+
271
+ // the drift window of an anchored file: every commit to `path` not reachable from the spec's version
272
+ // and not covered by a valid Spec-OK ack — the SAME set driftFor counts, exposed as commits so the
273
+ // anchor engine can probe each one. (fileCommits comes from a --name-only walk, which lists no files
274
+ // for merge commits — the window is non-merge by construction.)
275
+ export function windowCommits(idx: DriftIndex, sinceHash: string, path: string): string[] {
276
+ if (!sinceHash) return []
277
+ const base = ancestorsOf(idx, sinceHash)
278
+ if (!base) return []
279
+ const cover = ackCoverFor(idx, sinceHash)
280
+ return (idx.fileCommits.get(path) ?? []).filter((h) => !inAncestors(idx, base, h) && !cover.some((a) => inAncestors(idx, a, h)))
281
+ }
282
+
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[]> {
289
+ const hits: AnchorHit[] = []
290
+ for (const c of win) {
291
+ const at = await unitsAt(root, c, path, x)
292
+ 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
296
+ 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 })
298
+ }
299
+ return hits
300
+ }
@@ -425,20 +425,16 @@ if (cmd === 'serve') {
425
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}`)
426
426
  }
427
427
  } else if (sub === 'lint') {
428
- const { specLint, driftGate, DRIFT_GUIDANCE } = await import('./lint.js')
428
+ const { specLint, DRIFT_GUIDANCE } = await import('./lint.js')
429
429
  const findings = await specLint()
430
430
  const errors = findings.filter((f) => f.level === 'error')
431
431
  for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
432
432
  console.error(`spex spec lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
433
- // drift teaches + gates from the ONE `spex spec lint` (no flag): print the remediation guidance wherever
434
- // drift exists, then apply the commit-local gate which reads the staged index itself, so it only
435
- // blocks an in-flight commit that touches an already heavily-drifted node. CI/manual (nothing staged)
436
- // stays advisory, per the ci-gate contract.
437
- const { blocked, touched, threshold } = await driftGate()
438
- if (findings.some((f) => f.rule === 'drift') || touched.length) console.error(`\n${DRIFT_GUIDANCE}`)
439
- for (const t of touched) console.error(` ${t.drift >= threshold ? '✗' : '•'} drift-gate: '${t.id}' is ${t.drift} behind${t.drift >= threshold ? ' — BLOCKS this commit' : ' (advisory)'}`)
440
- if (blocked.length) console.error(`\n✗ SpexCode: ${blocked.join(', ')} ${blocked.length === 1 ? 'is' : 'are'} ≥ ${threshold} commit(s) behind. Reconcile (above) or bypass with SPEXCODE_SKIP_LINT=1.`)
441
- process.exit(errors.length || blocked.length ? 1 : 0)
433
+ // drift teaches from the ONE `spex spec lint` (no flag). Unanchored drift stays advisory forever; the
434
+ // blocking tier is anchor-drift ([[code-anchor]])an ERROR like any other, so the pre-commit shim
435
+ // (and CI) gates on it with no separate staged-index machinery.
436
+ if (findings.some((f) => f.rule === 'drift' || f.rule === 'anchor-drift')) console.error(`\n${DRIFT_GUIDANCE}`)
437
+ process.exit(errors.length ? 1 : 0)
442
438
  } else if (sub === 'ack') {
443
439
  // An EMPTY stamp commit on top of HEAD, never an amend: driftFor (git.ts) quiets every drift commit
444
440
  // REACHABLE from an ack, so a child stamp covers exactly what amending HEAD would — and it works where
@@ -451,13 +447,13 @@ if (cmd === 'serve') {
451
447
  const reason = (flag('reason') ?? '').trim()
452
448
  if (!nodes.length || !reason) {
453
449
  console.error('usage: spex spec ack <node-id>… --reason "<why this change keeps each spec valid>"')
454
- console.error(' --reason is required (it forces you to check before acking) and is NOT storedgit keeps only the Spec-OK trailer.')
450
+ console.error(' --reason is required (it forces you to check before acking) and is recorded in the ack commit\'s message body an ack that quiets an anchor hit is a strong claim, so its why must be durable.')
455
451
  process.exit(2)
456
452
  }
457
453
  try {
458
- git(['commit', '--only', '--allow-empty', '-m', `ack: Spec-OK ${nodes.join(', ')}`,
454
+ git(['commit', '--only', '--allow-empty', '-m', `ack: Spec-OK ${nodes.join(', ')}`, '-m', reason,
459
455
  ...nodes.flatMap((n) => ['--trailer', `Spec-OK: ${n}`])])
460
- console.log(`Spec-OK: ${nodes.join(', ')} → ${git(['rev-parse', '--short', 'HEAD']).trim()} (empty stamp commit; reason required, not stored)`)
456
+ console.log(`Spec-OK: ${nodes.join(', ')} → ${git(['rev-parse', '--short', 'HEAD']).trim()} (empty stamp commit; reason recorded in the commit body)`)
461
457
  } catch (e: any) {
462
458
  console.error(`ack failed: ${e?.message ?? e}`); process.exit(1)
463
459
  }
@@ -527,12 +523,12 @@ if (cmd === 'serve') {
527
523
  if (!n.hasEvalFile && !n.uncoveredFrontend) console.log(' (no eval.md — nothing declared to measure)')
528
524
  else if (n.hasEvalFile && !n.scenarios.length) console.log(' (eval.md declares no scenarios)')
529
525
  }
530
- } else if (['add', 'ls', 'scenario', 'lint', 'retract', 'clean'].includes(sub)) {
526
+ } else if (['add', 'ls', 'scenario', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
531
527
  // node-scoped verbs — thin route; the logic lives in spec-eval.
532
528
  const { runEval } = await import('../../spec-eval/src/cli.js')
533
529
  await flushExit(await runEval(process.argv.slice(3)))
534
530
  } else {
535
- console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | lint | retract | clean (spex help eval)`)
531
+ console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | lint | ok | retract | clean (spex help eval)`)
536
532
  if (!sub.startsWith('--')) console.error(` (the old \`spex eval <SEL>\` session read is now \`spex eval ls --session <SEL>\` [--export])`) // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
537
533
  process.exit(2)
538
534
  }
@@ -366,28 +366,36 @@ export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boo
366
366
  return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
367
367
  }
368
368
 
369
- // pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
370
- // in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
371
- //
372
- // `sinceHash` is the node's OWN latest version commit, so the node(s) it's a version of
373
- // (specNodes[sinceHash]) name the node being measured; an ack counts only if its `Spec-OK:` set names
374
- // one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that is itself an ancestor of the
375
- // version can't speak for it (a re-version invalidates older acks); a valid ack quiets exactly the
376
- // commits reachable from it. An off-history `sinceHash` → 0: no basis on HEAD to measure from.
377
- export function driftFor(idx: DriftIndex, sinceHash: string, path: string): number {
378
- if (!sinceHash) return 0
369
+ // the valid Spec-OK coverage for a node's version commit: `sinceHash` is the node's OWN latest version,
370
+ // so the node(s) it's a version of (specNodes[sinceHash]) name the node being measured; an ack counts
371
+ // only if its `Spec-OK:` set names one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that
372
+ // is itself an ancestor of the version can't speak for it (a re-version invalidates older acks); a valid
373
+ // ack quiets exactly the commits reachable from it. Shared by driftFor (the count) and the anchor
374
+ // engine's windowCommits (the commit set) so both read ONE ack rule.
375
+ export function ackCoverFor(idx: DriftIndex, sinceHash: string): Uint8Array[] {
379
376
  const base = ancestorsOf(idx, sinceHash)
380
- if (!base) return 0
377
+ if (!base) return []
381
378
  const targets = idx.specNodes.get(sinceHash)
382
- const ackCover: Uint8Array[] = []
379
+ const cover: Uint8Array[] = []
383
380
  if (targets) {
384
381
  for (const [h, ackSet] of idx.acks) {
385
382
  if (inAncestors(idx, base, h)) continue
386
383
  if (![...targets].some((t) => ackSet.has(t))) continue
387
384
  const a = ancestorsOf(idx, h)
388
- if (a) ackCover.push(a)
385
+ if (a) cover.push(a)
389
386
  }
390
387
  }
388
+ return cover
389
+ }
390
+
391
+ // pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
392
+ // in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
393
+ // An off-history `sinceHash` → 0: no basis on HEAD to measure from.
394
+ export function driftFor(idx: DriftIndex, sinceHash: string, path: string): number {
395
+ if (!sinceHash) return 0
396
+ const base = ancestorsOf(idx, sinceHash)
397
+ if (!base) return 0
398
+ const ackCover = ackCoverFor(idx, sinceHash)
391
399
  let n = 0
392
400
  for (const h of idx.fileCommits.get(path) ?? []) {
393
401
  if (inAncestors(idx, base, h)) continue // reachable from the version → not drift
@@ -62,6 +62,10 @@ 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 unit — an 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
69
  related: files this node REFERENCES but does not own — a YAML list, same path forms. Carries coverage
66
70
  (never drift, never eval freshness, nothing to ack); it is the many-to-many net that claims the files
67
71
  govern doesn't. Every listed path must exist (lint integrity error otherwise).
@@ -84,7 +88,15 @@ Bodies without those headings are read whole. Link sibling nodes with [[node-id]
84
88
  a REAL node (lint's mention rule; backtick a placeholder like \`[[node]]\` so it reads as sample text).
85
89
 
86
90
  WHAT lint CHECKS (spex spec lint; the pre-commit hook gates on errors):
87
- integrity (error) every code:/related: path exists.
91
+ integrity (error) every code:/related: path exists — and every anchor RESOLVES: a dead anchor (unit
92
+ deleted/renamed), an ambiguous one (two same-named units in one file), a file that
93
+ no longer parses, a language with no designated extractor, or an extractor that
94
+ 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
97
+ 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).
88
100
  one-govern (error) a node governs (code:) at most ONE file — keep the true subject, move the rest
89
101
  to related:.
90
102
  living (error) no "## vN" changelog headings — the body is current-state.
@@ -98,8 +110,12 @@ WHAT lint CHECKS (spex spec lint; the pre-commit hook gates on errors):
98
110
  twin; is an intermediate grouping layer missing?
99
111
  coverage (warn) every source file is claimed by ≥1 node — via code: OR related: (related is the net).
100
112
  drift (warn) a governed file has commits newer than the node's spec version — it may be stale.
101
- Remedy: edit the spec to the new intent (re-versions the node), OR \`spex spec ack <node>
102
- --reason "…"\` when only mechanics changed and the contract still holds.
113
+ 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.
117
+ anchor (warn) an anchor pins a type/interface — types reshape with every refactor; anchor the
118
+ behaviour-bearing unit instead.
103
119
  related-drift (warn) a related: file moved ahead of the node — a soft nudge, one summary line, never blocks.
104
120
  owners (warn) a file governed by > maxOwners nodes (default 3) does too much — SPLIT it so each
105
121
  governor owns its own module (or merge the nodes, or give it one foundation owner).
@@ -338,9 +354,10 @@ the guard (the flag is the declaration of intent). Reads point anywhere.
338
354
  lint.altitude body budgets: { lineBudget, charBudget, sizeable, dense, steps }
339
355
  (defaults 50 / 4200 / 35 / 1.3 / 3).
340
356
  lint.maxChildren breadth budget: warn at >= this many direct children (default 8).
341
- lint.driftErrorThreshold commit-local gate HARD-BLOCKS a commit touching a node >= this many commits
342
- behind (default 3).
343
357
  lint.maxOwners warn when a file is governed by > this many nodes (default 3).
358
+ (lint.driftErrorThreshold is RETIRED: the count-based commit gate is replaced
359
+ by code anchors — \`code: path#symbol\` — whose hits error unconditionally; a
360
+ leftover key is ignored.)
344
361
  lint.scenarioTags the closed vocabulary an eval scenario's tags: must draw from (default
345
362
  ["frontend-e2e","backend-api","cli","desktop","mobile"]); extend to mint a tag.
346
363
  Example — govern your own source dir and loosen the altitude budget:
@@ -45,7 +45,7 @@ derived status, title, and attention badges (drift:N · stale:N · issues:N · g
45
45
  },
46
46
  init: {
47
47
  line: 'init [dir] adopt SpexCode on a repo: seed .spec + hooks + materialize [--preset name]',
48
- body: `Usage: spex init [dir=cwd] [--preset default|careful]
48
+ body: `Usage: spex init [dir=cwd] [--preset default]
49
49
 
50
50
  Scaffolds adoption in one shot: seeds a starter .spec tree (project root + .plugins plugins), plants
51
51
  spexcode.json, installs the git hooks, and materializes the harness artifacts (contract block +
@@ -121,16 +121,20 @@ owner — the reverse edge: a file's GOVERNORS (code: — drives drift + eval fr
121
121
  — coverage only), with the verdict spelled out (uncovered / related-only / sanely governed /
122
122
  over-owned → split the file). --actionable prints NOTHING unless action is needed (hook use).
123
123
 
124
- lint — checks the whole spec↔code graph and exits non-zero on errors (or a blocked commit-local
125
- drift gate). Errors: integrity (a code:/related: file does not exist) · one-govern (a node governs >1
126
- file) · living (a "## vN" changelog heading) · id-format (an id char outside the whitelist — ascii
127
- [a-z0-9-] or a non-ascii unicode letter/number, CJK ok or a leaf id
128
- reused) · mention (a [[id]] naming no node). Warns: altitude · breadth · coverage · drift ·
129
- related-drift · owners · confusable-id (two leaf ids one edit apart). spec lint's errors BLOCK commits (the pre-commit shim; bypass SPEXCODE_SKIP_LINT=1);
124
+ 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, unackedthe blocking tier of
128
+ drift) · one-govern (a node governs >1 file) · living (a "## vN" changelog heading) · id-format (an
129
+ id char outside the whitelist ascii [a-z0-9-] or a non-ascii unicode letter/number, CJK ok or a
130
+ 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);
130
133
  contrast \`spex eval lint\`, which is pure advisory and never blocks anyone.
131
134
 
132
135
  ack — stamp Spec-OK on HEAD (an empty stamp commit): the drift remedy when only MECHANICS changed
133
- and the spec's contract still holds. --reason is required but NOT stored. If the intent DID change,
136
+ and the spec's contract still holds. --reason is required and recorded in the ack commit's body
137
+ (quieting an anchor hit is a strong claim — the why must be durable). If the intent DID change,
134
138
  edit the spec instead — same commit as the code.`,
135
139
  see: 'spex guide spec (the file format + every lint rule) · spex graph (browse the whole tree)',
136
140
  },
@@ -192,7 +196,7 @@ ${MENTION_NOTE}`,
192
196
  see: 'spex eval ls --session <SEL> (the session’s measured loss) · spex help eval',
193
197
  },
194
198
  eval: {
195
- line: 'eval <verb> the measurement system: add · ls · scenario ls · lint · retract · clean',
199
+ line: 'eval <verb> the measurement system: add · ls · scenario ls · lint · ok · retract · clean',
196
200
  body: `Usage: spex eval add [<node>|.] [--scenario <name>] (--pass|--fail) [--note <text>]
197
201
  [--image <png> …repeatable] [--result <path|->] [--video <webm|mp4>] [--timeline <json>]
198
202
  spex eval ls [<node>|.] [--json] a node's eval timeline, newest first
@@ -200,6 +204,7 @@ ${MENTION_NOTE}`,
200
204
  spex eval ls --session <SEL> --export [--open | --out <path>]
201
205
  spex eval scenario ls [<node>|.] [--unmeasured] [--json] declared scenarios; bare = every node
202
206
  spex eval lint [--changed] measurement-layer findings (advisory, always exit 0)
207
+ spex eval ok <node> [--scenario <name>] the HUMAN sign-off on the scenario's latest reading
203
208
  spex eval retract [<node>|.] [--scenario <name>] [--last | --ts <iso>] [--note <why>]
204
209
  spex eval clean [--keep-latest | --all] GC the content-addressed evidence cache
205
210
 
@@ -222,6 +227,11 @@ stale (eval-drift) · orphaned remark tracks (eval-dangling) · governed source
222
227
  files (eval-owners). --changed scopes to the nodes THIS branch touched. spec lint's errors block
223
228
  commits; eval lint is PURE ADVISORY, always exit 0 — a measurement gap never blocks anyone.
224
229
 
230
+ ok — the human's reviewed-and-agreed mark on the scenario's LATEST reading: an appended, monotonic
231
+ sign-off bound to that one immutable reading (a newer reading or staleness releases it on its own —
232
+ no un-ok exists). The evals feed default-hides a fresh, ok'd scenario; a governed session is refused
233
+ (an agent's judgment on a reading is a remark, never a self-blessing).
234
+
225
235
  retract — the sanctioned undo for a botched filing: APPENDS a retraction event (traceable, never
226
236
  deletes a line); the previous eval becomes latest again, or the scenario honestly returns to
227
237
  unmeasured.
@@ -21,6 +21,7 @@ import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
21
21
  import { putBlob } from '../../spec-eval/src/cache.js'
22
22
  import { evalNodes } from '../../spec-eval/src/scenarios.js'
23
23
  import { fileHumanReading } from '../../spec-eval/src/filing.js'
24
+ import { fileHumanOk } from '../../spec-eval/src/humanok.js'
24
25
  import { buildExportModel, renderExportHtml, buildSessionEvals } from '../../spec-eval/src/sessioneval.js'
25
26
  import { saveUpload, MAX_UPLOAD_BYTES } from './uploads.js'
26
27
  import { attachViewer, detachViewer, resizeBridge, forwardWheel, superviseBridges, type Viewer } from './pty-bridge.js'
@@ -113,6 +114,19 @@ app.post('/api/specs/:id/evals', async (c) => {
113
114
  const r = fileHumanReading(c.req.param('id'), b)
114
115
  return r.ok ? c.json({ ok: true, reading: r.reading }) : c.json({ error: r.error }, 400)
115
116
  })
117
+ // the HUMAN SIGN-OFF write ([[human-ok]]) — the dashboard's ok affordance and `spex eval ok` share this ONE
118
+ // write (LAW L: no dashboard-only path). Identity is SERVER-DERIVED 'human', never the request body (the
119
+ // same rule as /api/remarks). The write appends a monotonic human-ok event bound to the scenario's latest
120
+ // reading and — on the trunk checkout — commits it straight to trunk; the board cache is invalidated
121
+ // atomically with persistence so the writer's own refetch never races a stale cache.
122
+ app.post('/api/specs/:id/evals/ok', async (c) => {
123
+ const b = await c.req.json().catch(() => null)
124
+ if (!b || typeof b.scenario !== 'string') return c.json({ error: 'body needs { scenario }' }, 400)
125
+ const r = fileHumanOk(c.req.param('id'), b.scenario, 'human')
126
+ if (!r.ok) return c.json({ error: r.error }, 400)
127
+ notifyBoardChanged('full')
128
+ return c.json({ ok: true, already: r.already, humanOk: r.humanOk })
129
+ })
116
130
  // serve a reading's evidence blob by content hash (bytes never enter git): bad hash → 400, missing → 404,
117
131
  // else the bytes with a sniffed MIME and an immutable cache header (the name IS the content hash).
118
132
  // HTTP Range is honored — a <video> can only SEEK when the server answers byte ranges (a browser clamps
@@ -11,11 +11,12 @@ const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
11
11
  const TEMPLATES = join(pkgRoot, 'templates')
12
12
 
13
13
  // the cumulative preset chain, lean → cautious (see [[init-preset]]). `default` is the live `.plugins`
14
- // instance set (planted from templates/spec); every higher tier is a SEPARATE package under
15
- // templates/presets/<tier>/ that seeding stacks ON TOP — a superset, so selecting `careful` seeds the
16
- // default set PLUS the careful package. Selection matters ONLY here at seed time; the running repo just
14
+ // instance set (planted from templates/spec); a higher tier would be a SEPARATE package under
15
+ // templates/presets/<tier>/ that seeding stacks ON TOP — a superset. No non-default tier ships today
16
+ // (the `careful` package was retired); the chain mechanism stays for when one earns its keep.
17
+ // Selection matters ONLY here at seed time; the running repo just
17
18
  // walks whatever `.plugins` ended up planted, so there is no launcher-side preset gate.
18
- const PRESET_TIERS = ['default', 'careful'] as const
19
+ const PRESET_TIERS = ['default'] as const
19
20
  const presetRank = (name: string): number => (PRESET_TIERS as readonly string[]).indexOf(name)
20
21
 
21
22
  // recursively copy srcDir -> destDir, NEVER overwriting an existing file. Returns the repo-relative