spexcode 0.2.8 → 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/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +300 -0
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +691 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +23 -15
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +142 -98
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +300 -385
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +144 -103
- package/spec-cli/src/init.ts +14 -13
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +128 -30
- package/spec-cli/src/localIssues.ts +61 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +398 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +12 -12
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +40 -25
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/spec-eval/src/humanok.ts +43 -0
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/spec.md +0 -15
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
package/spec-cli/src/specs.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
|
2
2
|
import { readFile, readdir } from 'node:fs/promises'
|
|
3
3
|
import { join, relative, basename } from 'node:path'
|
|
4
4
|
import { repoRoot, historyIndex, rowsFor, statsFor, pathsStats, driftIndex, driftFor, fileDiffAt } from './git.js'
|
|
5
|
+
import { parseCodeEntry } from './anchors.js'
|
|
5
6
|
|
|
6
7
|
// a node is any directory under .spec holding a spec.md; its parent is the nearest ancestor that also holds one.
|
|
7
8
|
const ROOT = repoRoot()
|
|
@@ -97,8 +98,8 @@ function walk(dir: string, parent: string | null, acc: Raw[]) {
|
|
|
97
98
|
// single token — never a '/'-joined path, which would break every `:id` route and fetch that treats an id
|
|
98
99
|
// as one path segment. So the disambiguation separator is '_': like '/' it never occurs inside a dir
|
|
99
100
|
// basename (so the join stays unambiguous), but unlike '/' it is a URL/wikilink/DOM-safe unreserved char,
|
|
100
|
-
// so a collision-qualified id (e.g. `.
|
|
101
|
-
// Exported as the ONE mint every id producer shares: spec-
|
|
101
|
+
// so a collision-qualified id (e.g. `.plugins_<id>`) stays a single token everywhere it is resolved.
|
|
102
|
+
// Exported as the ONE mint every id producer shares: spec-eval mints its node ids through this same
|
|
102
103
|
// function over this same universe (every spec node), so a colliding leaf carries one canonical id
|
|
103
104
|
// system-wide instead of a second, diverging bare-leaf scheme.
|
|
104
105
|
export function mintIds(segs: string[][]): string[] {
|
|
@@ -135,7 +136,7 @@ function raws(): Raw[] {
|
|
|
135
136
|
return acc
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
// async twin of walk/raws for the HOT board build ([[
|
|
139
|
+
// async twin of walk/raws for the HOT board build ([[graph-cache]]): reading each spec.md through
|
|
139
140
|
// fs/promises YIELDS the event loop between files, so a build never stalls a `/health` liveness probe the
|
|
140
141
|
// way the sync walk (one ~450ms uninterrupted stretch) did. Same output as raws() — identical push order
|
|
141
142
|
// (pre-order DFS, dir before children) and the same reId — so every caller reads the same nodes; only
|
|
@@ -170,14 +171,14 @@ function claimMatcher(file: string): (cf: string) => boolean {
|
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
// spec node(s) that GOVERN a file (frontmatter `code:` — source of truth, drives drift
|
|
174
|
+
// spec node(s) that GOVERN a file (frontmatter `code:` — source of truth, drives drift + eval freshness); reads only
|
|
174
175
|
// frontmatter (cheap, no git) so a per-edit hook can call it.
|
|
175
176
|
export function specOwners(file: string): { id: string; desc: string }[] {
|
|
176
177
|
const claims = claimMatcher(file)
|
|
177
|
-
return raws().filter((r) => list(r.fm.code).some(claims)).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
|
|
178
|
+
return raws().filter((r) => list(r.fm.code).some((e) => claims(parseCodeEntry(e).path))).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
// spec node(s) that REFERENCE a file (frontmatter `related:` — carries coverage, never drift
|
|
181
|
+
// spec node(s) that REFERENCE a file (frontmatter `related:` — carries coverage, never drift, never eval freshness):
|
|
181
182
|
// [[governed-related]]'s other half, same claim rule, same cheap frontmatter-only read.
|
|
182
183
|
export function specRelated(file: string): { id: string; desc: string }[] {
|
|
183
184
|
const claims = claimMatcher(file)
|
|
@@ -211,7 +212,7 @@ export function loadSpecsLite(): SpecLite[] {
|
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
// one node's body + parsed parts, filesystem-only (no git). The board omits both to stay lean
|
|
214
|
-
// ([[
|
|
215
|
+
// ([[graph-lean]]); the detail view fetches them here when a node opens. null when the id isn't a node.
|
|
215
216
|
export function specContent(id: string): { body: string; parts: ReturnType<typeof parseParts> } | null {
|
|
216
217
|
const r = raws().find((x) => x.id === id)
|
|
217
218
|
return r ? { body: r.body.trim(), parts: parseParts(r.body) } : null
|
|
@@ -226,7 +227,11 @@ export async function loadSpecs() {
|
|
|
226
227
|
// session = the Session: trailer of the node's latest version; frontmatter `session:` is the fallback.
|
|
227
228
|
const fmSession = str(r.fm.session)
|
|
228
229
|
const session = h[0]?.session || (fmSession && fmSession !== 'null' ? fmSession : null)
|
|
229
|
-
|
|
230
|
+
// a code: entry may pin a symbol (`path#fn` — [[code-anchor]]): `code` carries the PATHS (what every
|
|
231
|
+
// path consumer — drift, claims, eval attribution — expects); the anchors ride separately for lint.
|
|
232
|
+
const codeEntries = list(r.fm.code).map(parseCodeEntry)
|
|
233
|
+
const code = codeEntries.map((e) => e.path)
|
|
234
|
+
const anchors = codeEntries.filter((e): e is { path: string; anchor: string } => e.anchor !== null)
|
|
230
235
|
const related = list(r.fm.related)
|
|
231
236
|
const S = h[0]?.hash || ''
|
|
232
237
|
const driftFiles = code
|
|
@@ -234,7 +239,7 @@ export async function loadSpecs() {
|
|
|
234
239
|
.filter((d) => d.behind > 0)
|
|
235
240
|
const drift = driftFiles.reduce((a, d) => a + d.behind, 0)
|
|
236
241
|
// related drift is the SOFT tier ([[governed-related]]): same ancestry basis, but it stays OUT of
|
|
237
|
-
// `drift` — it never feeds status, the commit gate, or
|
|
242
|
+
// `drift` — it never feeds status, the commit gate, or eval freshness. It surfaces only as a lint warn nudge.
|
|
238
243
|
const relatedDriftFiles = related
|
|
239
244
|
.map((f) => ({ file: f, behind: driftFor(didx, S, f) }))
|
|
240
245
|
.filter((d) => d.behind > 0)
|
|
@@ -250,6 +255,7 @@ export async function loadSpecs() {
|
|
|
250
255
|
hue: Number(str(r.fm.hue, '210')),
|
|
251
256
|
desc: str(r.fm.desc),
|
|
252
257
|
code,
|
|
258
|
+
anchors,
|
|
253
259
|
related,
|
|
254
260
|
version: h.length,
|
|
255
261
|
reason: h[0]?.reason || '',
|
|
@@ -271,7 +277,7 @@ export async function loadSpecs() {
|
|
|
271
277
|
export async function specHistory(id: string) {
|
|
272
278
|
const node = raws().find((r) => r.id === id)
|
|
273
279
|
if (!node) return []
|
|
274
|
-
const codePaths = list(node.fm.code)
|
|
280
|
+
const codePaths = list(node.fm.code).map((e) => parseCodeEntry(e).path)
|
|
275
281
|
// index (cached) and the code-path walk are independent — run them in parallel, both async git.
|
|
276
282
|
const [idx, cStats] = await Promise.all([historyIndex(ROOT), pathsStats(ROOT, codePaths)])
|
|
277
283
|
const sStats = statsFor(idx, node.relPath)
|
|
@@ -292,7 +298,7 @@ export async function specDiffAt(id: string, hash: string) {
|
|
|
292
298
|
return latestDiff(node.relPath, hash)
|
|
293
299
|
}
|
|
294
300
|
|
|
295
|
-
//
|
|
301
|
+
// plugin presets - REFLEXIVE, SKILL-SHAPED preset nodes whose folder IS a skill bundle: `spec.md`'s
|
|
296
302
|
// body is the agent prompt/contract (with a {{targets}} placeholder the launcher fills with the
|
|
297
303
|
// @-referenced nodes), and the SAME folder may co-locate auxiliary files — scripts, assets — that the
|
|
298
304
|
// preset ships for the agent to run deterministically. So each preset reports its folder `dir`
|
|
@@ -302,19 +308,19 @@ export async function specDiffAt(id: string, hash: string) {
|
|
|
302
308
|
// harness lifecycle events the node binds, its deterministic intra-event order, and whether it intends to
|
|
303
309
|
// block (honored only on block-capable events). See loadHookConfig + the hook compiler/dispatcher.
|
|
304
310
|
export type ConfigPreset = { name: string; title: string; desc: string; kind: string; dir: string; files: string[]; body: string; events: string[]; order: number; block: boolean; tools: string[] }
|
|
305
|
-
// field-driven surface - a
|
|
306
|
-
//
|
|
311
|
+
// field-driven surface - a plugin is a spec node at ANY depth under a plugin root that carries a
|
|
312
|
+
// `surface: system|command|hook|skill|agent` frontmatter field naming where it plugs in. There are no
|
|
307
313
|
// `command/`/`system/`/`hook/`/`skill/`/`agent/` bucket dirs (those were graph-invisible grouping dirs with no spec.md, so
|
|
308
314
|
// the spec graph skipped them — path != graph); the surface is a FIELD on the node, so the plugin is a real
|
|
309
|
-
// graph child
|
|
310
|
-
// `
|
|
315
|
+
// graph child (a grouping parent like `.plugins/prompts` is itself a spec node, never a bare dir). BOTH plugin roots participate: `.plugins` (the instance — DIY dev-flow plugins) and
|
|
316
|
+
// `plugin-system` (the project system spec). loadConfig gathers the `command` surface, loadSystemConfig the `system`
|
|
311
317
|
// surface, loadHookConfig the `hook` surface, loadSkillConfig the `skill` surface, loadAgentConfig the `agent`
|
|
312
318
|
// surface (sub-agent definitions); each scans the children under every root and filters by the field. The plugins also show on the board as ordinary spec nodes (via loadSpecs).
|
|
313
319
|
// root node - the spec tree's single top-level node: the one directory directly under .spec/ that
|
|
314
320
|
// holds a spec.md. The dogfood repo names it 'spexcode'; a repo scaffolded by `spex init` names it
|
|
315
321
|
// 'project' (or whatever the adopter renames it to). Detected DYNAMICALLY so the config loaders resolve
|
|
316
322
|
// the ACTUAL root's config dirs — never a hardcoded 'spexcode', which silently returned [] in an adopter
|
|
317
|
-
// repo, so their .
|
|
323
|
+
// repo, so their .plugins/core contract never loaded and their launched agents got no system prompt.
|
|
318
324
|
// Returns null when .spec holds no such directory. (resolveLayout's `main` is a checkout PATH, not the
|
|
319
325
|
// root node NAME, so it can't serve this — a tiny filesystem probe is the right seam.)
|
|
320
326
|
function rootNode(): string | null {
|
|
@@ -325,10 +331,20 @@ function rootNode(): string | null {
|
|
|
325
331
|
return null
|
|
326
332
|
}
|
|
327
333
|
// resolved at call time (not module-eval) so it tracks the live tree.
|
|
334
|
+
// @@@ legacy-tree refusal - v0.3.0 renamed the plugin instance root `.config` → `.plugins`. A pre-0.3.0
|
|
335
|
+
// tree would otherwise load an EMPTY plugin surface — no contract block, no hooks, no commands — and the
|
|
336
|
+
// launched agents would silently run ungoverned. So refuse loudly: existence-only probe (never a dual
|
|
337
|
+
// read of legacy content), pointing at the one-shot migrator. Delete this check in 0.4.0.
|
|
328
338
|
function configRoots(): string[] {
|
|
329
339
|
const root = rootNode()
|
|
330
340
|
if (!root) return []
|
|
331
|
-
|
|
341
|
+
if (existsSync(join(SPEC_DIR, root, '.config')) && !existsSync(join(SPEC_DIR, root, '.plugins'))) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`.spec/${root}/.config exists but .spec/${root}/.plugins does not — this spec tree predates the v0.3.0 ` +
|
|
344
|
+
`plugin rename (.config → .plugins). Refusing to load an empty plugin surface (agents would launch ` +
|
|
345
|
+
`ungoverned). Run \`spex doctor --migrate\` to migrate the tree.`)
|
|
346
|
+
}
|
|
347
|
+
return ['.plugins', 'plugin-system'].map((r) => join(SPEC_DIR, root, r))
|
|
332
348
|
}
|
|
333
349
|
// co-located bundle files = everything under the node folder except its spec.md, repo-relative, recursive.
|
|
334
350
|
function bundleFiles(dir: string): string[] {
|
|
@@ -343,14 +359,13 @@ function bundleFiles(dir: string): string[] {
|
|
|
343
359
|
walk(dir)
|
|
344
360
|
return out.sort()
|
|
345
361
|
}
|
|
346
|
-
// gather the preset nodes under a
|
|
347
|
-
// `surface` is a FIELD, not a path (the design's core tenet), so a plugin may live at ANY depth
|
|
348
|
-
// grouping
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
// the appended system prompt and the command dropdown) is byte-for-byte unchanged.
|
|
362
|
+
// gather the preset nodes under a plugin root that declare `surface: <surface>`. The scan is RECURSIVE —
|
|
363
|
+
// `surface` is a FIELD, not a path (the design's core tenet), so a plugin may live at ANY depth: under a
|
|
364
|
+
// surface-less grouping shelf (the auxiliary `surface: system` contracts live under `.plugins/prompts/`),
|
|
365
|
+
// or under a plugin that is itself a grouping parent (`.plugins/core` is a `surface: system` contract whose
|
|
366
|
+
// CHILDREN are `surface: hook` nodes). The field filter keeps it safe: a node only gathers if it declares THIS
|
|
367
|
+
// surface, so descending past a matched node never double-counts (children carry a different surface),
|
|
368
|
+
// and the gather set is path-independent — regrouping a plugin never changes what materializes.
|
|
354
369
|
function loadSurface(surface: 'command' | 'system' | 'hook' | 'skill' | 'agent'): ConfigPreset[] {
|
|
355
370
|
const out: ConfigPreset[] = []
|
|
356
371
|
const visit = (nodeDir: string, name: string) => {
|
|
@@ -37,7 +37,7 @@ const repoRoot = join(here, '..', '..')
|
|
|
37
37
|
const watchRoots = [
|
|
38
38
|
here, // spec-cli/src — the backend's own source
|
|
39
39
|
join(repoRoot, 'spec-forge', 'src'),
|
|
40
|
-
join(repoRoot, 'spec-
|
|
40
|
+
join(repoRoot, 'spec-eval', 'src'),
|
|
41
41
|
]
|
|
42
42
|
|
|
43
43
|
type Backend = { port: number; child: ChildProcess }
|
|
@@ -128,7 +128,7 @@ const proxy = net.createServer((client) => {
|
|
|
128
128
|
client.once('close', () => up.destroy()) // client abandoned → reap its upstream (THE leak that wedged :8787); nothing left to flush to a gone client
|
|
129
129
|
// upstream gone → drop the client half, but ONLY force it when the close was ABNORMAL: on a normal FIN,
|
|
130
130
|
// `up.pipe(client)` has already called `client.end()` (writableEnded), so let the client flush the last of a
|
|
131
|
-
// large response (e.g. /api/
|
|
131
|
+
// large response (e.g. /api/graph) rather than truncate it with a destroy; a crash/half-open close (no prior
|
|
132
132
|
// end) still gets reaped.
|
|
133
133
|
up.once('close', () => { if (!client.writableEnded) client.destroy() })
|
|
134
134
|
client.pipe(up); up.pipe(client)
|
package/spec-cli/src/tree.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// @@@ spex
|
|
1
|
+
// @@@ spex graph - the CLI's human-readable graph view: the same assembled board the dashboard's
|
|
2
2
|
// tidy-tree renders, as an indented terminal tree. Pure presentation over buildBoard()'s nodes —
|
|
3
3
|
// no read path of its own; status colours and badge semantics mirror the dashboard (drift /
|
|
4
|
-
// stale-
|
|
4
|
+
// stale-eval / open-issues counts). Governed by the spex-tree spec node.
|
|
5
5
|
|
|
6
6
|
// the subset of a board node this view consumes (buildBoard attaches more; we read only these).
|
|
7
7
|
export type TreeNode = {
|
|
@@ -23,7 +23,7 @@ export type TreeOpts = { node?: string; depth?: number; color?: boolean }
|
|
|
23
23
|
// from the warning yellow), drift=yellow (the dashboard's warning colour), pending=muted grey.
|
|
24
24
|
const STATUS_ANSI: Record<string, string> = { merged: '32', active: '36', drift: '33', pending: '90' }
|
|
25
25
|
|
|
26
|
-
// stale-
|
|
26
|
+
// stale-eval count: declared scenarios whose LATEST reading exists but is no longer fresh.
|
|
27
27
|
// board `evals` is already latest-per-scenario, so this is a straight filter — the same freshness
|
|
28
28
|
// axis the dashboard's grey ✓/✗ badges read (score.jsx readingScore).
|
|
29
29
|
function staleYatsu(n: TreeNode): number {
|
|
@@ -48,7 +48,7 @@ function childrenIndex(nodes: TreeNode[]): Map<string | null, TreeNode[]> {
|
|
|
48
48
|
function roots(nodes: TreeNode[], nodeId?: string): TreeNode[] {
|
|
49
49
|
if (nodeId) {
|
|
50
50
|
const hit = nodes.find((n) => n.id === nodeId)
|
|
51
|
-
if (!hit) throw new Error(`no spec node "${nodeId}" — spex
|
|
51
|
+
if (!hit) throw new Error(`no spec node "${nodeId}" — spex graph lists every id; spex spec search <topic> finds one by intent`)
|
|
52
52
|
return [hit]
|
|
53
53
|
}
|
|
54
54
|
const ids = new Set(nodes.map((n) => n.id))
|
|
@@ -100,7 +100,7 @@ export function renderTree(nodes: TreeNode[], opts: TreeOpts = {}): string {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
// the machine exit: the same filtered subtree as NESTED objects, badge counts precomputed — a
|
|
103
|
-
// shaped view, not a replacement for `spex
|
|
103
|
+
// shaped view, not a replacement for `spex graph --json` (which stays the full flat payload). Pruned
|
|
104
104
|
// children degrade to their ids, so --depth still tells the machine what exists below the cut.
|
|
105
105
|
export function treeJson(nodes: TreeNode[], opts: TreeOpts = {}): object[] {
|
|
106
106
|
const byParent = childrenIndex(nodes)
|
|
@@ -13,7 +13,7 @@ import { dematerialize } from './materialize.js'
|
|
|
13
13
|
// and the optional git hooks. EVERY removal is gated on a SpexCode IDENTITY STAMP (the managed-block
|
|
14
14
|
// sentinels, the shim's own dispatch.sh command line, the trust sentinels, the generated mark / name-scoped
|
|
15
15
|
// on-demand paths, the plugin name stamp), so it can only ever delete what SpexCode itself generated. The one
|
|
16
|
-
// inviolable rule: the user's spec ASSET (.spec/.
|
|
16
|
+
// inviolable rule: the user's spec ASSET (.spec/.plugins) is NEVER touched — uninstall removes only generated
|
|
17
17
|
// WIRING, not the spec graph that wiring served.
|
|
18
18
|
|
|
19
19
|
// the standard plugin-host folders a host agent scans (in addition to any named in spexcode.json's `harnesses`).
|
|
@@ -85,7 +85,7 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
|
|
|
85
85
|
const proj = resolve(targetArg ?? process.cwd())
|
|
86
86
|
console.log(`spex uninstall → ${proj}`)
|
|
87
87
|
|
|
88
|
-
// cwd = the project so the .
|
|
88
|
+
// cwd = the project so the .plugins loaders read THIS tree's surface nodes (the live skill/agent names tell
|
|
89
89
|
// each adapter's clean() exactly which name-scoped on-demand files were its to remove).
|
|
90
90
|
const prevCwd = process.cwd()
|
|
91
91
|
let arts: HarnessArtifacts = { skills: [], agents: [] }
|
|
@@ -93,7 +93,7 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
|
|
|
93
93
|
process.chdir(proj)
|
|
94
94
|
arts = { skills: loadSkillConfig().map((s) => s.name), agents: loadAgentConfig().map((a) => a.name) }
|
|
95
95
|
} catch {
|
|
96
|
-
// no readable .
|
|
96
|
+
// no readable .plugins (already partly torn down, or never adopted) — clean still strips the harness wiring.
|
|
97
97
|
} finally {
|
|
98
98
|
process.chdir(prevCwd)
|
|
99
99
|
}
|
|
@@ -140,6 +140,6 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
|
|
|
140
140
|
|
|
141
141
|
console.log(`
|
|
142
142
|
SpexCode wiring removed. Your spec data is untouched:
|
|
143
|
-
• .spec/ and .
|
|
143
|
+
• .spec/ and .plugins/ remain — your spec graph is YOURS, never deleted by uninstall.
|
|
144
144
|
• To re-adopt later: \`spex init\` regenerates the shims, contract, trust, and global store.`)
|
|
145
145
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# @@@ footprint refresh (post-checkout) ([[commit-surgery]]) - a branch checkout is one of the three git
|
|
3
|
-
# transitions that can move the materialize's inputs: .spec/.
|
|
3
|
+
# transitions that can move the materialize's inputs: .spec/.plugins content changes across branches, and a
|
|
4
4
|
# contract file's TRACKEDNESS can flip (switching to a branch that tracks CLAUDE.md checks out the pristine
|
|
5
5
|
# index prose — the re-materialize here writes the block back into the working file, binds the clean/smudge
|
|
6
6
|
# filter, and withdraws the exclude entry; the kind-transition heals itself). Git-native anchors only —
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# command's own output — a git-native nudge that costs no harness block-cap and reaches a self-launched agent
|
|
6
6
|
# too. GUARDED to the `merge node/<id>:` commit, so an ordinary `git pull`/unrelated merge never nags.
|
|
7
7
|
#
|
|
8
|
-
# The nudge TEXT and the on/off toggle both live in the CLI (`spex
|
|
8
|
+
# The nudge TEXT and the on/off toggle both live in the CLI (`spex internal nudge <node>` prints nothing when
|
|
9
9
|
# the feature is OFF in spexcode.json), so this hook is a THIN resolver + caller: resolve `spex` the same
|
|
10
10
|
# three ways the pre-commit hook does, pass the merged node, echo whatever it prints. No CLI resolvable → no
|
|
11
11
|
# nudge (safe degradation, same as the lint shim's advisory mode).
|
|
@@ -19,7 +19,7 @@ spex_run() {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
# @@@ footprint refresh ([[commit-surgery]]) - a merge is one of the three git transitions that can move the
|
|
22
|
-
# materialize's inputs (.spec/.
|
|
22
|
+
# materialize's inputs (.spec/.plugins content, contract-file trackedness), so re-materialize here — git-native
|
|
23
23
|
# anchors only, never a harness event. Quiet, best-effort: a failed refresh self-heals at the next anchor.
|
|
24
24
|
spex_run internal refresh-footprint >/dev/null 2>&1 || true
|
|
25
25
|
|
|
@@ -29,5 +29,5 @@ case "$subj" in
|
|
|
29
29
|
*) exit 0 ;;
|
|
30
30
|
esac
|
|
31
31
|
node=$(printf '%s' "$subj" | sed -n 's#^merge \(node/[^:]*\):.*#\1#p')
|
|
32
|
-
spex_run
|
|
32
|
+
spex_run internal nudge "$node"
|
|
33
33
|
exit 0
|
|
@@ -30,7 +30,7 @@ spex_cli() {
|
|
|
30
30
|
# @@@ tree-unchanged stamp - a commit whose tree EQUALS HEAD's tree (`git write-tree` over the commit's own
|
|
31
31
|
# index — the hook's GIT_INDEX_FILE, a temp HEAD-tree index for `commit --only --allow-empty`) adds NO
|
|
32
32
|
# content: it cannot smuggle code past the main-guard below and cannot introduce spec↔code drift, so the
|
|
33
|
-
# whole hook passes it. This is `spex ack`'s empty Spec-OK stamp — the reason ack works on a trunk merge
|
|
33
|
+
# whole hook passes it. This is `spex spec ack`'s empty Spec-OK stamp — the reason ack works on a trunk merge
|
|
34
34
|
# commit (amending it would re-author the merge after MERGE_HEAD is gone and read as direct authoring) and
|
|
35
35
|
# with a DIRTY real index (the lint shim below reads the real index via git.ts's env-strip, which would
|
|
36
36
|
# otherwise block the stamp on the very drift it acknowledges). Deliberately a one-line tree compare, not an
|
|
@@ -60,7 +60,7 @@ trunk=$(spex_cli internal trunk 2>/dev/null | head -1)
|
|
|
60
60
|
if [ "$branch" = "$trunk" ] && [ ! -f "$git_dir/MERGE_HEAD" ] && [ -z "${SPEXCODE_ALLOW_MAIN:-}" ]; then
|
|
61
61
|
echo "✗ SpexCode: direct commits on $trunk (the trunk) are blocked." >&2
|
|
62
62
|
echo " Work in a worktree (.worktrees/<node>, branch node/<id>) and merge back." >&2
|
|
63
|
-
echo " Merges and tree-unchanged stamps (spex ack) pass automatically; for seeding/topology: SPEXCODE_ALLOW_MAIN=1 git commit …" >&2
|
|
63
|
+
echo " Merges and tree-unchanged stamps (spex spec ack) pass automatically; for seeding/topology: SPEXCODE_ALLOW_MAIN=1 git commit …" >&2
|
|
64
64
|
exit 1
|
|
65
65
|
fi
|
|
66
66
|
|
|
@@ -74,13 +74,13 @@ if [ -n "$spex_kind" ]; then
|
|
|
74
74
|
spex_cli internal commit-surgery || echo "• SpexCode: footprint surgery skipped (error above) — a generated artifact may be staged; CI still enforces." >&2
|
|
75
75
|
fi
|
|
76
76
|
|
|
77
|
-
# @@@ spec-lint - the hook is just a thin shim over `spex lint`. It blocks on errors only (broken
|
|
77
|
+
# @@@ spec-lint - the hook is just a thin shim over `spex spec lint`. It blocks on errors only (broken
|
|
78
78
|
# spec↔code links); coverage/drift are warnings. Bypass with SPEXCODE_SKIP_LINT=1. It shims through the
|
|
79
79
|
# shared spex_cli() resolved above (repoRoot() discovers the cwd's toplevel via git.ts, which strips the
|
|
80
80
|
# hook's GIT_DIR, so lint validates THIS worktree's `.spec`).
|
|
81
81
|
if [ -z "${SPEXCODE_SKIP_LINT:-}" ]; then
|
|
82
82
|
if [ -n "$spex_kind" ]; then
|
|
83
|
-
spex_cli lint >&2
|
|
83
|
+
spex_cli spec lint >&2
|
|
84
84
|
lint_rc=$?
|
|
85
85
|
# exit 75 = the launcher's mid-merge guard (spex's own source holds conflict markers — transient).
|
|
86
86
|
# Degrade to advisory rather than walling every commit behind a merge someone else is resolving.
|
|
@@ -90,11 +90,11 @@ if [ -z "${SPEXCODE_SKIP_LINT:-}" ]; then
|
|
|
90
90
|
echo "✗ SpexCode: spec-lint failed — fix the spec↔code links or bypass with SPEXCODE_SKIP_LINT=1." >&2
|
|
91
91
|
exit 1
|
|
92
92
|
else
|
|
93
|
-
# @@@
|
|
94
|
-
# belong in the shared git common dir, never committed) OR a malformed
|
|
95
|
-
# violation). Logic lives in spec-
|
|
96
|
-
if ! spex_cli
|
|
97
|
-
echo "✗ SpexCode:
|
|
93
|
+
# @@@ eval backstop - reject a staged eval offender: a stray evidence blob copied into the tree (blobs
|
|
94
|
+
# belong in the shared git common dir, never committed) OR a malformed eval.md (a scenario schema
|
|
95
|
+
# violation). Logic lives in spec-eval; this is the lint-shim's twin. (this node's contract)
|
|
96
|
+
if ! spex_cli internal check-staged >&2; then
|
|
97
|
+
echo "✗ SpexCode: internal check-staged failed (see above) — fix it or bypass with SPEXCODE_SKIP_LINT=1." >&2
|
|
98
98
|
exit 1
|
|
99
99
|
fi
|
|
100
100
|
fi
|
|
@@ -34,7 +34,7 @@ fi
|
|
|
34
34
|
|
|
35
35
|
[ -z "$sid" ] && exit 0
|
|
36
36
|
# Stamp via interpret-trailers (git's own sample-hook mechanism), NOT a raw printf append: appending after a
|
|
37
|
-
# message that already carries a trailer block (e.g. `spex ack`'s Spec-OK) opens a new paragraph, and git then
|
|
37
|
+
# message that already carries a trailer block (e.g. `spex spec ack`'s Spec-OK) opens a new paragraph, and git then
|
|
38
38
|
# parses ONLY the last paragraph as trailers — the earlier trailers silently become body prose. interpret-trailers
|
|
39
39
|
# appends into the existing block, keeping ONE parseable block. It needs the file newline-terminated first:
|
|
40
40
|
# on an unterminated message (git merge -m writes MERGE_MSG without one) it folds the trailer into the subject
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# On an idle_prompt notification, mark the session idle (the active-only guard in `session
|
|
2
|
+
# On an idle_prompt notification, mark the session idle (the active-only guard in `internal session-idle` keeps a
|
|
3
3
|
# deliberate awaiting/asking/parked/error declaration from being clobbered). GATED on `governed`: only a
|
|
4
4
|
# dashboard-launched session has board state to mark — a self-launched agent's idle is none of our business.
|
|
5
5
|
# State lives in the per-session GLOBAL record session.json (keyed by the harness session_id, grouped per-
|
|
@@ -12,4 +12,4 @@ sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
|
12
12
|
sdir=$(hp_store_dir "$sid") || exit 0
|
|
13
13
|
rec="$sdir/session.json"
|
|
14
14
|
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
15
|
-
[ "$(hp_notification_type "$payload")" = idle_prompt ] && exec ${SPEX:-spex} session
|
|
15
|
+
[ "$(hp_notification_type "$payload")" = idle_prompt ] && exec ${SPEX:-spex} internal session-idle --session "$sid"
|
package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh
RENAMED
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
# (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
|
|
16
16
|
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
17
17
|
payload=$(cat 2>/dev/null)
|
|
18
|
+
# an IN-PROCESS SUBAGENT's tool call (Claude's Task tool) fires the parent's hooks with the PARENT's
|
|
19
|
+
# session_id — flipping here let a supervising parent's own subagents erase its declared park/ask within
|
|
20
|
+
# seconds and race the stop-gate into "undeclared stop" (issue #60). A subagent working is not the parent
|
|
21
|
+
# agent ACTING, so its calls never touch the record; the parent's own next tool call still flips. The
|
|
22
|
+
# discriminator is the payload's own top-level agent_id stamp (hp_is_subagent) — deterministic, never a
|
|
23
|
+
# timing window.
|
|
24
|
+
[ -n "$(hp_is_subagent "$payload")" ] && exit 0
|
|
18
25
|
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
19
26
|
sdir=$(hp_store_dir "$sid") || exit 0
|
|
20
27
|
rec="$sdir/session.json"
|
|
@@ -13,4 +13,6 @@ The single freshness signal for a session. Any work a session does — a new pro
|
|
|
13
13
|
|
|
14
14
|
The state is read from ONE structured field in the hook payload, never sniffed from the terminal UI, so the signal is hard rather than guessed. Because it fires before the tool runs, a deliberate [[stop-gate]] declaration (itself made via a tool) lands after this and wins; the next real tool flips back to `active`, forcing a fresh declaration at the following stop. It is pure shell so it stays cheap firing on every tool call.
|
|
15
15
|
|
|
16
|
+
The one activity that does NOT count as the session acting is an IN-PROCESS SUBAGENT's tool call (the harness's Task tool — a sub-conversation inside the same process). Such a call fires the parent's hooks carrying the parent's `session_id`, so without a discriminator a supervising parent could never hold a declared state: its own subagents erased every park/ask within seconds and raced the stop-gate into "undeclared stop". The harness stamps subagent-executed calls with a top-level `agent_id` field the parent's own calls never carry; `hp_is_subagent` reads that stamp deterministically (scanning only the pre-`tool_input` payload prefix, where a tool parameter or file content can never fake an unescaped key), and this hook skips the flip entirely. A subagent working is its parent supervising, not the parent moving on — the parent's own next tool call still flips as before.
|
|
17
|
+
|
|
16
18
|
This is the freshness half of the [[core]] discipline: it keeps the board honest about whether a session is working, waiting, or asking, so the gates and the dashboard read a true present state rather than a stale one.
|
|
@@ -9,4 +9,4 @@ sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
|
9
9
|
sdir=$(hp_store_dir "$sid") || exit 0
|
|
10
10
|
rec="$sdir/session.json"
|
|
11
11
|
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
12
|
-
exec ${SPEX:-spex} session
|
|
12
|
+
exec ${SPEX:-spex} internal session-fail --session "$sid"
|
|
@@ -48,7 +48,7 @@ if [ -n "$node" ]; then
|
|
|
48
48
|
sp=$(find .spec -path "*/$node/spec.md" 2>/dev/null | head -1)
|
|
49
49
|
where="your node's spec (${sp:-.spec/.../$node/spec.md})"
|
|
50
50
|
else
|
|
51
|
-
where="the spec node that governs this area (run: spex search <topic>)"
|
|
51
|
+
where="the spec node that governs this area (run: spex spec search <topic>)"
|
|
52
52
|
fi
|
|
53
53
|
printf '{"decision":"block","reason":"Before working in this code, read %s FIRST — it is the current contract — and read its NEIGHBORS too (the parent that scopes it, the siblings it borders, the children that refine it), since its intent is only fully legible against the surrounding tree. Then act deliberately: changing the intent? edit the spec first so spec and code land together. implementing existing intent? make the code honor the spec. The one forbidden move is code that silently diverges from its spec. (Fires once per session, at your first code read or edit.)"}\n' "$where"
|
|
54
54
|
exit 0
|
package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh
RENAMED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# much and points at the split — so the contract is in view AT THE MOMENT OF THE EDIT, not just later at
|
|
5
5
|
# commit (lint/drift). NON-BLOCKING (additionalContext only — never a verdict) and dedup'd PER FILE via a
|
|
6
6
|
# ledger, so a 50-edit refactor annotates each file ONCE. Uses MAIN's tsx+cli ($SPEX) for the file→spec
|
|
7
|
-
# resolve (`spex owner`); cwd = the session worktree.
|
|
7
|
+
# resolve (`spex spec owner`); cwd = the session worktree.
|
|
8
8
|
# @@@ harness-agnostic - WHICH tool/path counts as a code MUTATION is the [[harness-adapter]]'s call, read via
|
|
9
9
|
# hp_code_path … mutate (Claude Edit/Write/NotebookEdit + file_path; Codex tool_name:Bash + an apply_patch /
|
|
10
10
|
# write-shape command). So this annotates edits on Claude AND Codex.
|
|
@@ -52,7 +52,7 @@ while IFS= read -r path; do
|
|
|
52
52
|
path=$(hp_actionable_repo_path "$path") || continue
|
|
53
53
|
[ -f "$led" ] && grep -qxF -- "$path" "$led" && continue
|
|
54
54
|
mkdir -p "$sdir"; echo "$path" >> "$led"
|
|
55
|
-
m=$(cd "$repo" && $S owner "$path" --actionable 2>/dev/null) # --actionable: silent on a sanely-owned file; speaks only for an OVER-owned / uncovered file
|
|
55
|
+
m=$(cd "$repo" && $S spec owner "$path" --actionable 2>/dev/null) # --actionable: silent on a sanely-owned file; speaks only for an OVER-owned / uncovered file
|
|
56
56
|
[ -n "$m" ] || continue
|
|
57
57
|
msg="${msg:+$msg
|
|
58
58
|
}$m"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: core
|
|
3
|
+
surface: system
|
|
4
|
+
status: active
|
|
5
|
+
hue: 200
|
|
6
|
+
desc: A config plugin — the minimal spec-discipline contract folded into every launched agent.
|
|
7
|
+
code:
|
|
8
|
+
---
|
|
9
|
+
The CLI speaks ONE grammar: `spex <noun> <verb> [object] [flags]`. Six nouns — `spec` (the governance graph), `session` (the worktree state machine), `eval` (the measurement system), `issue` (concern threads), `remark` (resolvable pins), `evidence` (content-addressed bytes) — plus a few bare project verbs (`graph` · `init` · `materialize` · `doctor` · `serve` · `uninstall`) whose object is always this project. The verb is always the token right after its noun; a bare noun prints its drawer's help; a `--help` probe is always safe (it prints and exits before the verb runs). `SEL` = session id | unique id-prefix | node id | branch; `.` as a node argument = the node this worktree works on. When unsure of a spelling, ask the tool — `spex help`, `spex help <command>` — never guess from memory: removed spellings only report their replacement and exit.
|
|
10
|
+
|
|
11
|
+
Four disciplines, non-negotiable:
|
|
12
|
+
|
|
13
|
+
1. SPEC FIRST. Before you touch code — and merely READING it counts, not only editing — read the governing spec's BODY: its actual prose, not the title, not the one-line desc, not your memory of it. The body is the current contract; code and its comments tell you what the code DOES, only the spec tells you what it is SUPPOSED to do. Don't know which node governs the area? `spex spec search <topic>` — not grep: grep finds code by architectural centrality, search finds intent by user-story. Read the NEIGHBORS' bodies too — the parent that scopes it, the siblings it borders, the children that refine it — a node's intent is only legible against the tree around it. If your task changes the intent, edit the spec first so spec and code land together; if it implements existing intent, make the code honor the spec. The one forbidden move is code that silently diverges from its spec.
|
|
14
|
+
|
|
15
|
+
2. COMMIT BEFORE YOU DECLARE. Commit your spec node and the code it justifies BEFORE you declare done or propose merge — the commit comes first, never as an afterthought to a declaration. An independently-scoped feature gets its OWN sibling spec node, not a ride-along in your assigned node's commit (cosmetic polish riding along is the smell).
|
|
16
|
+
|
|
17
|
+
3. THE BODY IS A LIVING CURRENT-STATE DOCUMENT. It states the node's PRESENT intent and is rewritten in place. Never accrete a "## vN" changelog heading, and never add current-state or verdict sections — version history is git's job, not the body's.
|
|
18
|
+
|
|
19
|
+
4. KEEP THE LOSS SIGNAL HONEST for what you changed — eval readings are the signal the optimizer reads, so a gap is a blind spot. Changed a node that carries a `eval.md`? Re-measure it: run its scenario, compare to the expected, and file the result with `spex eval add <node>`. Made an obvious frontend change to a node with NO `eval.md`? Give it one — a scenario (description + expected) — so its loss can be measured. A frontend scenario is measured through the **actual running product** — drive a real browser, read the real DOM and capture a screenshot (or video), never reason about the code — and that real observation is filed as the reading, not left as an ad-hoc check you ran but never recorded. `spex eval lint --changed` shows the gaps in exactly the nodes you touched.
|
|
20
|
+
|
|
21
|
+
Don't reverse-engineer the file formats: `spex guide spec` and `spex guide eval` print the full spec.md and scenario schema on demand. This prompt is the clue; that manual carries the detail.
|
|
@@ -35,33 +35,33 @@ proposal=$(jget proposal)
|
|
|
35
35
|
# BSD sed has no \| alternation.)
|
|
36
36
|
cont=$(printf '%s' "$input" | sed -n 's/.*"stop_hook_active"[[:space:]]*:[[:space:]]*\([a-z]*\).*/\1/p')
|
|
37
37
|
|
|
38
|
-
# @@@
|
|
39
|
-
# done/awaiting declaration): the agent IS
|
|
40
|
-
# blind spot to flag the moment work lands. SCOPED via `spex
|
|
38
|
+
# @@@ eval advisory - a nudge (never a gate) emitted when a session stops CLEAN-DONE (committed work + a
|
|
39
|
+
# done/awaiting declaration): the agent IS the measuring hand, so an eval gap in what it just changed is a
|
|
40
|
+
# blind spot to flag the moment work lands. SCOPED via `spex eval lint --changed` to the nodes THIS branch
|
|
41
41
|
# touched — so an agent is never nagged about a score that went stale in a node it never opened (the bug
|
|
42
|
-
# that made three workers ask "is this mine?"). Three gap classes it surfaces:
|
|
43
|
-
# (a node with
|
|
44
|
-
#
|
|
42
|
+
# that made three workers ask "is this mine?"). Three gap classes it surfaces: eval-drift / eval-missing
|
|
43
|
+
# (a node with an eval.md whose score is stale / unmeasured) and eval-coverage (a FRONTEND node with no
|
|
44
|
+
# eval.md — an obvious UI change carrying no loss signal). Delivered via the Stop hook's additionalContext
|
|
45
45
|
# (NEVER a block decision: a gap is a heads-up, not a wall). FIRES ONCE: the additionalContext itself forces
|
|
46
46
|
# one continuation, so the CALLER guards it on stop_hook_active — re-emitting on the forced re-stop is what
|
|
47
47
|
# looped 31 turns and tripped the Stop-hook block cap. Called only on ALLOW paths, never alongside a block.
|
|
48
48
|
#
|
|
49
49
|
# SURFACE-NEUTRAL: a stale/unmeasured score is refreshed only by PRODUCING the measurement on the scenario's
|
|
50
50
|
# OWN surface — a real run, never a desk check and never deferring to review a recording after the fact. The
|
|
51
|
-
# nudge privileges NO surface: `
|
|
52
|
-
# ([[
|
|
51
|
+
# nudge privileges NO surface: `eval lint --changed` carries each drift/missing scenario's tag on its finding line
|
|
52
|
+
# ([[eval-core]]'s lint.scenarioTags — frontend-e2e / backend-api / cli / desktop / mobile), so the agent
|
|
53
53
|
# reads there WHICH surface to run. One line covers all five surfaces; there is no per-surface branch.
|
|
54
|
-
|
|
54
|
+
eval_advisory() {
|
|
55
55
|
local out ids n msg esc
|
|
56
56
|
# Codex Stop hooks reject the Claude-family `hookSpecificOutput.additionalContext` shape on allow paths.
|
|
57
57
|
# Keep Codex Stop stdout empty unless it is a real block decision; the dispatcher still bridges block
|
|
58
58
|
# reasons to Codex stderr.
|
|
59
59
|
[ "${SPEXCODE_HARNESS:-claude}" = codex ] && return 0
|
|
60
|
-
out=$($S
|
|
61
|
-
n=$(printf '%s\n' "$out" | grep -cE '
|
|
62
|
-
[ "${n:-0}" -gt 0 ] || return 0 # no gap in what you changed (or
|
|
63
|
-
ids=$(printf '%s\n' "$out" | sed -n "s/.*
|
|
64
|
-
msg="
|
|
60
|
+
out=$($S eval lint --changed 2>&1)
|
|
61
|
+
n=$(printf '%s\n' "$out" | grep -cE 'eval-(drift|missing|coverage):')
|
|
62
|
+
[ "${n:-0}" -gt 0 ] || return 0 # no gap in what you changed (or eval lint unavailable) -> nothing to nudge
|
|
63
|
+
ids=$(printf '%s\n' "$out" | sed -n "s/.*eval-[a-z]*: '\([^']*\)'.*/\1/p" | awk '!seen[$0]++' | head -6 | paste -sd' ' -)
|
|
64
|
+
msg="eval — the loss signal the optimizer reads — flags ${n} gap(s) in nodes you changed: ${ids}. A node whose score went stale/unmeasured: re-measure it — PRODUCE the measurement YOURSELF with a real run of the scenario's actual surface (its tag on the \`spex eval lint --changed\` line tells you WHICH surface to run), compare to expected, and file it with \`spex eval add <node>\`; don't desk-check it, and don't defer to reviewing a recording after the fact. A FRONTEND node with no eval.md: give it one (a scenario — description + expected), since an obvious UI change should carry a loss signal. \`spex eval lint --changed\` lists them. (Advisory — fires once, not a gate.)"
|
|
65
65
|
esc=$(printf '%s' "$msg" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
|
66
66
|
printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"%s"}}\n' "$esc"
|
|
67
67
|
}
|
|
@@ -69,17 +69,17 @@ yatsu_advisory() {
|
|
|
69
69
|
# @@@ commit gate - a declaration of done/merge (awaiting + proposal merge|nothing) is only honest once the
|
|
70
70
|
# node branch carries the work as COMMITS: the dogfood ritual commits spec+code BEFORE any proposal, yet a
|
|
71
71
|
# dashboard-launched agent kept proposing merge with 0 commits / a dirty tree. So before allowing such a
|
|
72
|
-
# declaration we run the deterministic check (`spex
|
|
72
|
+
# declaration we run the deterministic check (`spex internal commit-gate`, which goes through git.ts's git()
|
|
73
73
|
# so the hook's GIT_DIR/GIT_INDEX_FILE can't misdirect repo discovery). Clean -> allow. Dirty/0-ahead ->
|
|
74
74
|
# block ONCE with the specific reason + commit instructions; on the forced continuation (the agent ignored
|
|
75
75
|
# it) escape the loop by downgrading to `asking` (needs the human) with a clear note, so a FALSE "ready to
|
|
76
76
|
# merge" never stands. (A propose-close declaration is exempt — it discards the worktree, so commits are moot.)
|
|
77
77
|
if [ "${status:-active}" = awaiting ] && { [ "$proposal" = merge ] || [ "$proposal" = nothing ]; }; then
|
|
78
|
-
if gatemsg=$($S
|
|
78
|
+
if gatemsg=$($S internal commit-gate 2>&1); then
|
|
79
79
|
# nudge ONCE: emit on the natural stop, but STAY SILENT on the forced re-stop the additionalContext
|
|
80
80
|
# itself causes (stop_hook_active=true). Without this guard the advisory re-fired every clean-done stop
|
|
81
81
|
# and looped — the bug a prior change DESCRIBED in a comment but never actually implemented at the call.
|
|
82
|
-
[ "$cont" != true ] &&
|
|
82
|
+
[ "$cont" != true ] && eval_advisory
|
|
83
83
|
exit 0 # work is committed and ahead of main -> the proposal is honest, let it stop.
|
|
84
84
|
fi
|
|
85
85
|
if [ "$cont" = true ]; then
|
|
@@ -98,9 +98,9 @@ if [ "$cont" = true ]; then
|
|
|
98
98
|
# the forced continuation also stopped without declaring -> escape the loop, don't block. Keep the commit
|
|
99
99
|
# gate airtight: default to awaiting/nothing only when the branch is actually committed+ahead; otherwise an
|
|
100
100
|
# undeclared stop with uncommitted work becomes `asking` (needs the human), never a false awaiting/done.
|
|
101
|
-
if $S
|
|
102
|
-
$S session
|
|
103
|
-
# NOTE: no
|
|
101
|
+
if $S internal commit-gate >/dev/null 2>&1; then
|
|
102
|
+
$S internal session-state awaiting --session "$sid" --propose nothing --note "auto: stopped without declaring" >/dev/null 2>&1 || true
|
|
103
|
+
# NOTE: no eval nudge on the auto-declare path. It only runs on the forced continuation (cont=true),
|
|
104
104
|
# where a guarded advisory could never fire anyway, and an unguarded one was a second loop vector (a
|
|
105
105
|
# mark-active tool call could re-enter this branch). The clean-done path above is the single nudge site.
|
|
106
106
|
else
|
|
@@ -134,5 +134,5 @@ touch "$taught" 2>/dev/null || true
|
|
|
134
134
|
# tool calls honestly re-flips the record to active (mark-active, by design) and re-blocks the next stop;
|
|
135
135
|
# this block text is the one place every undeclared stopper is guaranteed to read, so the teaching that
|
|
136
136
|
# kills the park->block->re-park loop at its source lives here.
|
|
137
|
-
printf '{"decision":"block","reason":"Your session state is a CLAIM the
|
|
137
|
+
printf '{"decision":"block","reason":"Your session state is a CLAIM the graph, your supervisor, and other agents act on — not a box to tick to end the turn. Stopping undeclared makes your outcome a guess. Pick the ONE that is TRUE right now and run `%s session <choice>`, choosing the <choice> whose condition holds:\\n • done --propose merge — spec+code COMMITTED on the branch and genuinely ready for a human to review/merge (not just probably-done).\\n • done --propose nothing — committed, but you are NOT proposing a merge; paused for the human to look.\\n • park --note <what-you-await> — ONLY when a real BACKGROUND TASK will wake you (a spex session wait you backgrounded, a running build/job). If nothing is actually running to resume you, you are NOT parked — you are waiting on the human, so use ask; never use park as a default to clear this gate.\\n • done --propose close — you propose discarding this worktree.\\n • ask --note <your-question> — you need the human: a real question, or you are simply stopped awaiting direction; you resume only when they reply.\\n\\nDECLARE LAST, THEN STOP: finish everything else in the turn first — speak, send your messages, arm your background waits — and make the declaration your FINAL call. Any tool call AFTER it flips your record back to active (mark-active, by design: activity is activity), so the next stop re-blocks and demands a fresh declaration; declaring last kills that loop at its source.\\n\\n(This full explanation shows once per session; later undeclared stops get a one-line reminder. `%s help session` re-explains the choices any time.)"}\n' "$S" "$S"
|
|
138
138
|
exit 0
|
|
@@ -19,14 +19,14 @@ Input: a session id — SpexCode's, a bare harness id (claude / codex thread), o
|
|
|
19
19
|
- **SpexCode session** (first choice — the join is first-class): its record is
|
|
20
20
|
`~/.spexcode/projects/*/sessions/<id>/session.json` — glob for the id, prefix ok. Take `worktree_path`,
|
|
21
21
|
`branch`, `harness`, `harness_session_id`, `status`, `title`; the originating goal is
|
|
22
|
-
`spex session
|
|
22
|
+
`spex session show <id>` (the record's prompt). For a claude-harness session the transcript id IS the SpexCode session id;
|
|
23
23
|
for codex it is `harness_session_id`.
|
|
24
24
|
- **Any other session**: treat the arg as the harness's own id. The transcript carries `cwd` (and, unless
|
|
25
25
|
the worktree was detached, a branch) — the digest header surfaces them; that is your join to its desk.
|
|
26
26
|
|
|
27
27
|
## 2 · digest the transcript — mechanical first, model second
|
|
28
28
|
|
|
29
|
-
`node .spec/<root>/.
|
|
29
|
+
`node .spec/<root>/.plugins/distill/digest.mjs <id-or-path>` locates the transcript (claude:
|
|
30
30
|
`$CLAUDE_CONFIG_DIR` and every `~/.claude*` config dir → `projects/*/<id>.jsonl`; codex: `$CODEX_HOME` or
|
|
31
31
|
`~/.codex` → `sessions/**/rollout-*<id>.jsonl`) and prints a compact digest: the human's prompts in full,
|
|
32
32
|
the agent's own text, tool calls as one-liners, error results, and a footer with the files it edited and
|