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.
- package/package.json +1 -1
- package/spec-cli/src/anchors.ts +300 -0
- package/spec-cli/src/cli.ts +11 -15
- package/spec-cli/src/git.ts +21 -13
- package/spec-cli/src/guide.ts +22 -5
- package/spec-cli/src/help.ts +19 -9
- package/spec-cli/src/index.ts +14 -0
- package/spec-cli/src/init.ts +5 -4
- package/spec-cli/src/lint.ts +55 -22
- package/spec-cli/src/localIssues.ts +19 -0
- package/spec-cli/src/migrate-table.ts +13 -12
- package/spec-cli/src/search.bench.mjs +2 -2
- package/spec-cli/src/specs.ts +19 -14
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +5 -2
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-C7Bzsv86.js → Dashboard-CTcH2eW9.js} +3 -3
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-D5iB4Ac2.js → FoldToggle-CVFbBpyW.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-CMFTsQhg.js → IssuesPage-kULjonqj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-DwuTKgdP.js → MobileApp-B0ZJju8K.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CBS5_cmK.js → SessionInterface-BRKJqU2U.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionWindow-CqAnjWfI.js → SessionWindow-CDhEL7wO.js} +7 -7
- package/spec-dashboard/dist/assets/{Settings-BW5f0OaW.js → Settings-BL6FV_8S.js} +1 -1
- package/spec-dashboard/dist/assets/{index-Cc26X4ce.css → index-DmQsNYKK.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Ce0wDyQS.js → index-DulGPk6A.js} +7 -7
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +50 -3
- package/spec-eval/src/evaltab.ts +11 -3
- package/spec-eval/src/humanok.ts +43 -0
- package/spec-eval/src/sidecar.ts +35 -9
- package/spec-cli/templates/presets/careful/.plugins/clarify-before-code/spec.md +0 -11
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +0 -2
- /package/spec-cli/templates/spec/project/.plugins/{forge-link → prompts/forge-link}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{memory-hygiene → prompts/memory-hygiene}/spec.md +0 -0
- /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
|
+
"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
|
+
}
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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,
|
|
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
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/spec-cli/src/git.ts
CHANGED
|
@@ -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
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
|
|
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
|
|
377
|
+
if (!base) return []
|
|
381
378
|
const targets = idx.specNodes.get(sinceHash)
|
|
382
|
-
const
|
|
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)
|
|
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
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
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:
|
package/spec-cli/src/help.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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, unacked — the 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
|
|
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.
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -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
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -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);
|
|
15
|
-
// templates/presets/<tier>/ that seeding stacks ON TOP — a superset
|
|
16
|
-
//
|
|
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'
|
|
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
|