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/spec-cli/src/specs.ts
CHANGED
|
@@ -2,7 +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
|
+
import { parseCodeEntry, parseRelation } from './anchors.js'
|
|
6
6
|
|
|
7
7
|
// a node is any directory under .spec holding a spec.md; its parent is the nearest ancestor that also holds one.
|
|
8
8
|
const ROOT = repoRoot()
|
|
@@ -172,17 +172,21 @@ function claimMatcher(file: string): (cf: string) => boolean {
|
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
// spec node(s) that GOVERN a file (frontmatter `code:` — source of truth, drives drift + eval freshness); reads only
|
|
175
|
-
// frontmatter (cheap, no git) so a per-edit hook can call it.
|
|
176
|
-
|
|
175
|
+
// frontmatter (cheap, no git) so a per-edit hook can call it. `scoped` = every claiming entry carries a
|
|
176
|
+
// `#selector` — such a governor still displays, but does not count toward the owners bound ([[code-anchor]]).
|
|
177
|
+
export function specOwners(file: string): { id: string; desc: string; scoped: boolean }[] {
|
|
177
178
|
const claims = claimMatcher(file)
|
|
178
|
-
return raws().
|
|
179
|
+
return raws().flatMap((r) => {
|
|
180
|
+
const entries = list(r.fm.code).map(parseCodeEntry).filter((e) => claims(e.path))
|
|
181
|
+
return entries.length ? [{ id: r.id, desc: str(r.fm.desc), scoped: entries.every((e) => e.anchor !== null) }] : []
|
|
182
|
+
})
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
// spec node(s) that REFERENCE a file (frontmatter `related:` — carries coverage, never drift, never eval freshness):
|
|
182
186
|
// [[governed-related]]'s other half, same claim rule, same cheap frontmatter-only read.
|
|
183
187
|
export function specRelated(file: string): { id: string; desc: string }[] {
|
|
184
188
|
const claims = claimMatcher(file)
|
|
185
|
-
return raws().filter((r) => list(r.fm.related).some(claims)).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
|
|
189
|
+
return raws().filter((r) => list(r.fm.related).some((e) => claims(parseCodeEntry(e).path))).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
|
|
186
190
|
}
|
|
187
191
|
|
|
188
192
|
// memo fileDiffAt by (version sha + spec.md path) — a commit's patch is immutable. Keyed by path too: one
|
|
@@ -227,12 +231,18 @@ export async function loadSpecs() {
|
|
|
227
231
|
// session = the Session: trailer of the node's latest version; frontmatter `session:` is the fallback.
|
|
228
232
|
const fmSession = str(r.fm.session)
|
|
229
233
|
const session = h[0]?.session || (fmSession && fmSession !== 'null' ? fmSession : null)
|
|
230
|
-
// a code:
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
234
|
+
// a code:/related: row may pin symbols (`path#fn` — [[code-anchor]]): parseRelation groups each
|
|
235
|
+
// relation per BASE path, so `code`/`related` carry the distinct PATHS (what every path consumer —
|
|
236
|
+
// drift, claims, eval attribution — expects, file-level as before), the scoped entries (path +
|
|
237
|
+
// selectors) ride separately for lint's anchor engine, and structural problems (duplicates,
|
|
238
|
+
// bare/scoped mixing, glob selectors, the code cap) surface as lint integrity errors.
|
|
239
|
+
const codeRel = parseRelation(list(r.fm.code), 'code')
|
|
240
|
+
const relatedRel = parseRelation(list(r.fm.related), 'related')
|
|
241
|
+
const code = codeRel.entries.map((e) => e.path)
|
|
242
|
+
const codeScoped = codeRel.entries.filter((e) => e.selectors.length > 0)
|
|
243
|
+
const related = relatedRel.entries.map((e) => e.path)
|
|
244
|
+
const relatedScoped = relatedRel.entries.filter((e) => e.selectors.length > 0)
|
|
245
|
+
const relationProblems = [...codeRel.problems, ...relatedRel.problems]
|
|
236
246
|
const S = h[0]?.hash || ''
|
|
237
247
|
const driftFiles = code
|
|
238
248
|
.map((f) => ({ file: f, behind: driftFor(didx, S, f) }))
|
|
@@ -240,8 +250,10 @@ export async function loadSpecs() {
|
|
|
240
250
|
const drift = driftFiles.reduce((a, d) => a + d.behind, 0)
|
|
241
251
|
// related drift is the SOFT tier ([[governed-related]]): same ancestry basis, but it stays OUT of
|
|
242
252
|
// `drift` — it never feeds status, the commit gate, or eval freshness. It surfaces only as a lint warn nudge.
|
|
243
|
-
|
|
244
|
-
|
|
253
|
+
// A SCOPED related entry is excluded here: its file-level movement is silent by design — only a
|
|
254
|
+
// selector HIT warns, and that verdict needs the anchor engine, so lint derives it, not the loader.
|
|
255
|
+
const relatedDriftFiles = relatedRel.entries.filter((e) => !e.selectors.length)
|
|
256
|
+
.map((e) => ({ file: e.path, behind: driftFor(didx, S, e.path) }))
|
|
245
257
|
.filter((d) => d.behind > 0)
|
|
246
258
|
const fmStatus = str(r.fm.status, '') || null
|
|
247
259
|
return {
|
|
@@ -255,8 +267,10 @@ export async function loadSpecs() {
|
|
|
255
267
|
hue: Number(str(r.fm.hue, '210')),
|
|
256
268
|
desc: str(r.fm.desc),
|
|
257
269
|
code,
|
|
258
|
-
|
|
270
|
+
codeScoped,
|
|
259
271
|
related,
|
|
272
|
+
relatedScoped,
|
|
273
|
+
relationProblems,
|
|
260
274
|
version: h.length,
|
|
261
275
|
reason: h[0]?.reason || '',
|
|
262
276
|
// ISO date of the node's latest version commit (h is newest-first), or null if unversioned.
|
|
@@ -277,7 +291,7 @@ export async function loadSpecs() {
|
|
|
277
291
|
export async function specHistory(id: string) {
|
|
278
292
|
const node = raws().find((r) => r.id === id)
|
|
279
293
|
if (!node) return []
|
|
280
|
-
const codePaths = list(node.fm.code).map((e) => parseCodeEntry(e).path)
|
|
294
|
+
const codePaths = [...new Set(list(node.fm.code).map((e) => parseCodeEntry(e).path))]
|
|
281
295
|
// index (cached) and the code-path walk are independent — run them in parallel, both async git.
|
|
282
296
|
const [idx, cStats] = await Promise.all([historyIndex(ROOT), pathsStats(ROOT, codePaths)])
|
|
283
297
|
const sStats = statsFor(idx, node.relPath)
|
|
@@ -309,7 +323,7 @@ export async function specDiffAt(id: string, hash: string) {
|
|
|
309
323
|
// block (honored only on block-capable events). See loadHookConfig + the hook compiler/dispatcher.
|
|
310
324
|
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[] }
|
|
311
325
|
// 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
|
|
326
|
+
// `surface: system|command|hook|skill|agent|review` frontmatter field naming where it plugs in. There are no
|
|
313
327
|
// `command/`/`system/`/`hook/`/`skill/`/`agent/` bucket dirs (those were graph-invisible grouping dirs with no spec.md, so
|
|
314
328
|
// the spec graph skipped them — path != graph); the surface is a FIELD on the node, so the plugin is a real
|
|
315
329
|
// 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
|
|
@@ -366,7 +380,7 @@ function bundleFiles(dir: string): string[] {
|
|
|
366
380
|
// CHILDREN are `surface: hook` nodes). The field filter keeps it safe: a node only gathers if it declares THIS
|
|
367
381
|
// surface, so descending past a matched node never double-counts (children carry a different surface),
|
|
368
382
|
// and the gather set is path-independent — regrouping a plugin never changes what materializes.
|
|
369
|
-
function loadSurface(surface: 'command' | 'system' | 'hook' | 'skill' | 'agent'): ConfigPreset[] {
|
|
383
|
+
function loadSurface(surface: 'command' | 'system' | 'hook' | 'skill' | 'agent' | 'review'): ConfigPreset[] {
|
|
370
384
|
const out: ConfigPreset[] = []
|
|
371
385
|
const visit = (nodeDir: string, name: string) => {
|
|
372
386
|
if (existsSync(join(nodeDir, 'spec.md'))) {
|
|
@@ -417,3 +431,7 @@ export function loadSkillConfig(): ConfigPreset[] { return loadSurface('skill')
|
|
|
417
431
|
// .claude/agents/<name>.md). Like a skill, the node's `desc` is the on-demand load-trigger and its `body` is the
|
|
418
432
|
// agent's system prompt; additionally its `tools` field is the harness tool allowlist for the spawned agent.
|
|
419
433
|
export function loadAgentConfig(): ConfigPreset[] { return loadSurface('agent') }
|
|
434
|
+
// the review-track prose presets ([[review-commands]]): offered in the eval detail's remark-composer `/`
|
|
435
|
+
// dropdown; picking one PREFILLS the composer with the node's `body` ({node}/{scenario}/{expected}
|
|
436
|
+
// placeholders filled at insert time). Display+prefill only — the send stays the ordinary remark write.
|
|
437
|
+
export function loadReviewConfig(): ConfigPreset[] { return loadSurface('review') }
|
|
@@ -26,7 +26,12 @@ if [ -z "$sid" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then # codex: alias the threa
|
|
|
26
26
|
if [ -e "$rd/sessions/$CODEX_THREAD_ID/session.json" ]; then
|
|
27
27
|
sid="$CODEX_THREAD_ID" # a governed record keyed by the thread id (rare/direct)
|
|
28
28
|
else # else the record that captured it as harness_session_id
|
|
29
|
-
|
|
29
|
+
# @@@ unmatched-is-noop - grep's no-match (exit 1) and an absent sessions glob (exit 2) are the
|
|
30
|
+
# NOT-GOVERNED case, not an error: any repo inherits a foreign CODEX_THREAD_ID from a codex session's
|
|
31
|
+
# shell, and under `set -euo pipefail` a bare grep|head assignment would abort the hook — and the
|
|
32
|
+
# commit — before the intended `[ -z "$sid" ] && exit 0` no-op. Genuine errors past this lookup
|
|
33
|
+
# (interpret-trailers etc.) still fail loud via set -e.
|
|
34
|
+
hit=$(grep -lF "\"harness_session_id\": \"$CODEX_THREAD_ID\"" "$rd"/sessions/*/session.json 2>/dev/null | head -1) || hit=""
|
|
30
35
|
[ -n "$hit" ] && sid=$(basename "$(dirname "$hit")")
|
|
31
36
|
fi
|
|
32
37
|
fi
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: commands
|
|
3
|
+
status: active
|
|
4
|
+
hue: 40
|
|
5
|
+
desc: Grouping shelf for the `surface: command` plugins — the `/`-dropdown launch presets a new session can pick. A shelf, not a surface — routing stays field-driven.
|
|
6
|
+
---
|
|
7
|
+
# commands
|
|
8
|
+
|
|
9
|
+
The invocable **command** plugins live here: leaf plugins whose body is a launch preset a new session
|
|
10
|
+
picks from the `/` dropdown, each carrying `surface: command`. Grouping them keeps `.plugins/` legible at
|
|
11
|
+
a glance — the command presets on this shelf, the skill plugins on `skills/`, the auxiliary system
|
|
12
|
+
contracts on `prompts/`, with `core` a flat child beside them.
|
|
13
|
+
|
|
14
|
+
This node is a **shelf, not a surface**: it declares no `surface` field and gathers nothing itself.
|
|
15
|
+
Discovery is recursive and field-driven, so a resident plugs in exactly as it would at the root — a
|
|
16
|
+
plugin that serves both surfaces shelves once by its primary identity, never duplicated.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: skills
|
|
3
|
+
status: active
|
|
4
|
+
hue: 280
|
|
5
|
+
desc: Grouping shelf for the `surface: skill` plugins — the harness skills an agent invokes on demand. A shelf, not a surface — routing stays field-driven.
|
|
6
|
+
---
|
|
7
|
+
# skills
|
|
8
|
+
|
|
9
|
+
The invocable **skill** plugins live here: leaf plugins that materialize into the harness's skill dir,
|
|
10
|
+
where an agent invokes them on demand, each carrying `surface: skill`. Grouping them keeps `.plugins/`
|
|
11
|
+
legible at a glance — the skill plugins on this shelf, the command presets on `commands/`, the auxiliary
|
|
12
|
+
system contracts on `prompts/`, with `core` a flat child beside them.
|
|
13
|
+
|
|
14
|
+
This node is a **shelf, not a surface**: it declares no `surface` field and gathers nothing itself.
|
|
15
|
+
Discovery is recursive and field-driven, so a resident plugs in exactly as it would at the root. A plugin
|
|
16
|
+
that serves both surfaces shelves here by its primary (skill) identity and still gathers as a command
|
|
17
|
+
through its field.
|
|
@@ -8,9 +8,9 @@ desc: The instance home — this repo's DIY dev-flow plugins live here as skill-
|
|
|
8
8
|
working in it. Each plugin is a skill-shaped node — its folder *is* the unit (a `spec.md` plus any
|
|
9
9
|
co-located scripts) — carrying a `surface: command|system|…` field that names where it plugs in.
|
|
10
10
|
Discovery is recursive and field-driven, so plugins may sit under a grouping shelf: the auxiliary
|
|
11
|
-
`surface: system` prompt contracts live under `prompts/`,
|
|
12
|
-
|
|
13
|
-
children here.
|
|
11
|
+
`surface: system` prompt contracts live under `prompts/`, the `surface: command` presets under
|
|
12
|
+
`commands/`, the `surface: skill` plugins under `skills/`, while `core` — the contract subsystem whose
|
|
13
|
+
children are the `surface: hook` gates — sits as a flat child here.
|
|
14
14
|
|
|
15
15
|
The launcher's system gather and the new-session dropdown read from here. Only **active** plugins
|
|
16
16
|
gather: a `pending` node is declared intent, not yet an active plugin. The seed ships `core`
|