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.
- package/package.json +1 -1
- package/spec-cli/src/anchors.ts +53 -12
- package/spec-cli/src/cli.ts +9 -5
- package/spec-cli/src/gateway.ts +8 -1
- package/spec-cli/src/graph.ts +13 -7
- package/spec-cli/src/guide.ts +52 -21
- package/spec-cli/src/harness.ts +85 -34
- package/spec-cli/src/help.ts +20 -14
- package/spec-cli/src/index.ts +25 -7
- package/spec-cli/src/lint.ts +91 -49
- package/spec-cli/src/migrate-table.ts +16 -6
- package/spec-cli/src/session-timeline.ts +148 -0
- package/spec-cli/src/sessions.ts +29 -4
- package/spec-cli/src/specs.ts +35 -17
- package/spec-cli/templates/hooks/prepare-commit-msg +6 -1
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +16 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +17 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +3 -3
- package/spec-dashboard/dist/assets/{Dashboard-CTcH2eW9.js → Dashboard-C_fGmOKK.js} +3 -3
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-CVFbBpyW.js → FoldToggle-x9gtO1OQ.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-kULjonqj.js → IssuesPage-5f_vL-JV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +66 -0
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +13 -0
- package/spec-dashboard/dist/assets/{Settings-BL6FV_8S.js → Settings-DKb5Ji_X.js} +1 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +41 -0
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +1 -0
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +6 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +32 -18
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
- package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
- package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
- package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{supervisor → commands/supervisor}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{tidy → commands/tidy}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
- /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.
|
|
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",
|
package/spec-cli/src/anchors.ts
CHANGED
|
@@ -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
|
|
284
|
-
// line range extracted from the file AS IT EXISTED AT THAT COMMIT (never from HEAD — units later
|
|
285
|
-
// or moved still attribute correctly).
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
295
|
-
|
|
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
|
-
|
|
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
|
}
|
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
425
|
-
console.log(`${rel} is governed by ${
|
|
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')
|
package/spec-cli/src/gateway.ts
CHANGED
|
@@ -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))
|
|
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)
|
package/spec-cli/src/graph.ts
CHANGED
|
@@ -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 —
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
export function slimScenarios(
|
|
36
|
-
|
|
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 —
|
|
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
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -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
|
|
66
|
-
method; top-level functions, arrow/const declarations,
|
|
67
|
-
type/interface anchor warns).
|
|
68
|
-
|
|
65
|
+
The entry may pin named units — ANCHORS: 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
|
-
|
|
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.
|
|
99
|
-
|
|
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).
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
145
|
-
|
|
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.
|
|
380
|
-
|
|
381
|
-
|
|
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) ──
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -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 —
|
|
137
|
-
//
|
|
138
|
-
//
|
|
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
|
|
165
|
-
//
|
|
166
|
-
// `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
|
|
167
|
-
// and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
|
|
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 -
|
|
256
|
-
// `
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
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:
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
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
|
|
294
|
-
// Then
|
|
295
|
-
|
|
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
|
|
298
|
-
|
|
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
|
|
package/spec-cli/src/help.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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\`
|
|
126
|
-
whose language has no designated extractor or whose extractor can't run here
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
132
|
-
|
|
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) ·
|