spexcode 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/anchors.ts +300 -0
  3. package/spec-cli/src/cli.ts +11 -15
  4. package/spec-cli/src/git.ts +21 -13
  5. package/spec-cli/src/guide.ts +22 -5
  6. package/spec-cli/src/help.ts +19 -9
  7. package/spec-cli/src/index.ts +14 -0
  8. package/spec-cli/src/init.ts +5 -4
  9. package/spec-cli/src/lint.ts +55 -22
  10. package/spec-cli/src/localIssues.ts +19 -0
  11. package/spec-cli/src/migrate-table.ts +13 -12
  12. package/spec-cli/src/search.bench.mjs +2 -2
  13. package/spec-cli/src/specs.ts +19 -14
  14. package/spec-cli/templates/spec/project/.plugins/core/spec.md +1 -1
  15. package/spec-cli/templates/spec/project/.plugins/extract/spec.md +1 -1
  16. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  17. package/spec-cli/templates/spec/project/.plugins/spec.md +5 -2
  18. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +1 -1
  19. package/spec-cli/templates/spec/project/spec.md +1 -1
  20. package/spec-dashboard/dist/assets/{Dashboard-C7Bzsv86.js → Dashboard-CTcH2eW9.js} +3 -3
  21. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
  22. package/spec-dashboard/dist/assets/{FoldToggle-D5iB4Ac2.js → FoldToggle-CVFbBpyW.js} +1 -1
  23. package/spec-dashboard/dist/assets/{IssuesPage-CMFTsQhg.js → IssuesPage-kULjonqj.js} +1 -1
  24. package/spec-dashboard/dist/assets/{MobileApp-DwuTKgdP.js → MobileApp-B0ZJju8K.js} +1 -1
  25. package/spec-dashboard/dist/assets/{SessionInterface-CBS5_cmK.js → SessionInterface-BRKJqU2U.js} +1 -1
  26. package/spec-dashboard/dist/assets/{SessionWindow-CqAnjWfI.js → SessionWindow-CDhEL7wO.js} +7 -7
  27. package/spec-dashboard/dist/assets/{Settings-BW5f0OaW.js → Settings-BL6FV_8S.js} +1 -1
  28. package/spec-dashboard/dist/assets/{index-Cc26X4ce.css → index-DmQsNYKK.css} +1 -1
  29. package/spec-dashboard/dist/assets/{index-Ce0wDyQS.js → index-DulGPk6A.js} +7 -7
  30. package/spec-dashboard/dist/index.html +2 -2
  31. package/spec-eval/src/cli.ts +50 -3
  32. package/spec-eval/src/evaltab.ts +11 -3
  33. package/spec-eval/src/humanok.ts +43 -0
  34. package/spec-eval/src/sidecar.ts +35 -9
  35. package/spec-cli/templates/presets/careful/.plugins/clarify-before-code/spec.md +0 -11
  36. package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +0 -2
  37. /package/spec-cli/templates/spec/project/.plugins/{forge-link → prompts/forge-link}/spec.md +0 -0
  38. /package/spec-cli/templates/spec/project/.plugins/{memory-hygiene → prompts/memory-hygiene}/spec.md +0 -0
  39. /package/spec-cli/templates/spec/project/.plugins/{reproduce-before-fix → prompts/reproduce-before-fix}/spec.md +0 -0
@@ -1,8 +1,9 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { repoRoot, stagedFiles, git } from './git.js'
3
+ import { repoRoot, git, driftIndex, historyIndex, rowsFor } from './git.js'
4
4
  import { loadSpecs } from './specs.js'
5
5
  import { readJsonConfig } from './layout.js'
6
+ import { extractors, extractorFor, extOf, resolveAnchor, windowCommits, anchorHitCommits } from './anchors.js'
6
7
 
7
8
  export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
8
9
 
@@ -13,7 +14,6 @@ export type LintConfig = {
13
14
  identifierExtensions: string[]// extensions the altitude bare-filename signal recognises (see IDENT below)
14
15
  altitude: { lineBudget: number; charBudget: number; sizeable: number; dense: number; steps: number }
15
16
  maxChildren: number // breadth budget: warn at >= this many direct children
16
- driftErrorThreshold: number// commit-local gate HARD-BLOCKS a commit touching a node >= this many commits behind
17
17
  maxOwners: number // warn when a file is governed (code:) by > this many nodes
18
18
  scenarioTags: string[] // the closed vocabulary an eval scenario's `tags:` must draw from; extend it to mint a new tag
19
19
  }
@@ -24,7 +24,6 @@ const DEFAULT_CONFIG: LintConfig = {
24
24
  identifierExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'md'],
25
25
  altitude: { lineBudget: 50, charBudget: 4200, sizeable: 35, dense: 1.3, steps: 3 },
26
26
  maxChildren: 8,
27
- driftErrorThreshold: 3,
28
27
  maxOwners: 3,
29
28
  scenarioTags: ['frontend-e2e', 'backend-api', 'cli', 'desktop', 'mobile'],
30
29
  }
@@ -277,6 +276,59 @@ export async function specLint(): Promise<Finding[]> {
277
276
  out.push({ level: 'warn', rule: 'owners', msg: `${over.length} file(s) are governed by > ${cfg.maxOwners} nodes — each holds more separately-specified functionality than one file should. Worst: ${top}. SPLIT the file so each governor owns its own module (or merge the nodes, or give it a single foundation owner + related:).` })
278
277
  }
279
278
 
279
+ // code anchors ([[code-anchor]]): a code: entry may pin one named unit (`path#symbol`). The anchor is
280
+ // the BLOCKING tier of drift: a window commit (spec's last version..HEAD, non-merge, touching the
281
+ // governed file) whose --unified=0 hunks intersect the unit's line range — extracted from the file AS
282
+ // OF that commit, by the extension's ONE designated extractor — is an anchor-drift ERROR unless a
283
+ // Spec-OK ack covers it. Resolution failures are never silent: a dead or ambiguous anchor, an
284
+ // unparseable working-tree file, an extension with no designated extractor, and a designated extractor
285
+ // that can't run here (no host typescript) all ERROR with the repair spelled out.
286
+ const regs = extractors(root)
287
+ const [didx, hidx] = await Promise.all([driftIndex(root), historyIndex(root)])
288
+ const readyWarned = new Set<string>()
289
+ for (const s of specs) {
290
+ for (const { path, anchor } of s.anchors) {
291
+ const x = extractorFor(regs, extOf(path))
292
+ if (!x) {
293
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${anchor}, but no extractor is designated for '.${extOf(path)}' files — this language has no anchor support yet: add a LangSpec row (anchors.ts) or drop the #${anchor}` })
294
+ continue
295
+ }
296
+ const ready = x.ready()
297
+ if (ready !== true) {
298
+ // once per (extractor, reason), even across several anchored nodes — one repair, one message.
299
+ if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready); out.push({ level: 'error', rule: 'integrity', msg: `anchor extractor '${x.id}' cannot run: ${ready}` }) }
300
+ continue
301
+ }
302
+ if (!existsSync(join(root, path))) continue // the missing FILE already errored above
303
+ let units
304
+ try { units = x.extract(readFileSync(join(root, path), 'utf8'), path) } catch (e: any) {
305
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `anchor ${path}#${anchor} ('${s.id}') is unverifiable — the current file does not parse: ${e?.message ?? e}` })
306
+ continue
307
+ }
308
+ const res = resolveAnchor(units, anchor)
309
+ if ('dead' in res) {
310
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `dead anchor: ${path}#${anchor} ('${s.id}') names no unit on the current tree — the unit was deleted or renamed; update the spec's code: entry to follow it` })
311
+ continue
312
+ }
313
+ if ('ambiguous' in res) {
314
+ out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `ambiguous anchor: ${path}#${anchor} ('${s.id}') names ${res.ambiguous} same-named units in one file — an anchor must be unique; rename one unit` })
315
+ continue
316
+ }
317
+ if (res.ok.typeOnly)
318
+ out.push({ level: 'warn', rule: 'anchor', spec: s.id, file: path, msg: `${path}#${anchor} anchors a ${res.ok.kind} — anchoring a type is usually wrong (types reshape with every refactor); anchor the behaviour-bearing unit instead` })
319
+ const since = rowsFor(hidx, s.path)[0]?.hash || ''
320
+ const win = windowCommits(didx, since, path)
321
+ if (!win.length) continue
322
+ const hits = await anchorHitCommits(root, win, path, anchor, x)
323
+ const unparseable = hits.filter((h) => h.unparseable)
324
+ if (hits.length) {
325
+ const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
326
+ const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
327
+ out.push({ level: 'error', rule: 'anchor-drift', spec: s.id, file: path, msg: `${path}#${anchor} was changed by ${hits.length} commit(s) since spec '${s.id}' v${s.version} [${shas}]${parseNote} — the anchored contract's code moved: update the spec, or 'spex spec ack ${s.id} --reason "…"' if the contract still holds` })
328
+ }
329
+ }
330
+ }
331
+
280
332
  // drift: a governed file has commits NOT yet reflected in its spec. Judged by true git ancestry —
281
333
  // loadSpecs computes `driftFiles` via driftFor() over the one cached driftIndex walk (git.ts): a
282
334
  // commit to the file counts iff it is NOT reachable from the spec's latest version (in-memory
@@ -321,22 +373,3 @@ Diagnose, then apply the one honest remedy:
321
373
  maps to a node, or file an issue and link it (defer honestly)
322
374
 
323
375
  Never patch. A reasoned ack or a real fix are recorded and re-judged at review; a blind ack is a lie.`
324
-
325
- // commit-local: an empty staged index (CI, audit) → no blockers, drift stays advisory; non-empty → block
326
- // only when an OWN staged file belongs to a node already >= driftErrorThreshold behind. Sub-threshold drift
327
- // on a touched node is returned for an advisory nudge; the backlog on untouched nodes never blocks.
328
- export async function driftGate(): Promise<{ blocked: string[]; touched: { id: string; drift: number }[]; threshold: number }> {
329
- const root = repoRoot()
330
- const cfg = loadConfig(root)
331
- const staged = stagedFiles(root)
332
- if (!staged.length) return { blocked: [], touched: [], threshold: cfg.driftErrorThreshold }
333
- const specs = await loadSpecs()
334
- const owners = new Map<string, string[]>()
335
- for (const s of specs) for (const f of s.code) owners.set(f, [...(owners.get(f) ?? []), s.id])
336
- const byId = new Map(specs.map((s) => [s.id, s]))
337
- const ids = new Set<string>()
338
- for (const f of staged) for (const o of owners.get(f) ?? []) ids.add(o)
339
- const touched = [...ids].map((id) => byId.get(id)!).filter((s) => s && s.drift > 0)
340
- .map((s) => ({ id: s.id, drift: s.drift })).sort((a, b) => b.drift - a.drift)
341
- return { blocked: touched.filter((t) => t.drift >= cfg.driftErrorThreshold).map((t) => t.id), touched, threshold: cfg.driftErrorThreshold }
342
- }
@@ -272,6 +272,25 @@ function writeStoreFile(p: Issue, message: string): boolean {
272
272
  return true
273
273
  }
274
274
 
275
+ // @@@ the shared trunk-data commit — the committing half of the store discipline, exported for OTHER
276
+ // git-tracked data writes that need a durable landing with no worktree ritual behind them (the eval
277
+ // sidecar's human-ok, [[human-ok]]). Same rules as the issue store's own write: only the trunk checkout
278
+ // itself commits (a linked-worktree caller gets 'not-primary' back and leaves its append for its own
279
+ // session's ritual commit — the sidecar, unlike the issue store, legitimately lives per-branch and
280
+ // merges); the commit is `--no-verify` and provably scoped to the one data path; the shared store lock
281
+ // serializes it against every other trunk data write so racing writers never fight over the git index.
282
+ export function commitTrunkData(relPath: string, message: string): 'committed' | 'no-op' | 'not-primary' {
283
+ if (overrideStoreDir()) return 'not-primary' // a disposable-store test rig commits nothing, ever
284
+ if (!isPrimaryCheckout()) return 'not-primary'
285
+ return withStoreLock(() => {
286
+ const root = mainCheckout()
287
+ git(['-C', root, 'add', '--', relPath])
288
+ if (!git(['-C', root, 'status', '--porcelain', '--', relPath]).trim()) return 'no-op'
289
+ git(['-C', root, 'commit', '--no-verify', '-m', message, '--', relPath])
290
+ return 'committed'
291
+ })
292
+ }
293
+
275
294
  // prepare (a FRESH read-modify or a new thread) + write + commit a single store file, all under the store
276
295
  // lock so the read-modify-write is atomic. prepare() runs INSIDE the lock, so a reply/close reads the
277
296
  // current thread, never a stale copy. A pre-rename store migrates first (before the lock — ensure takes it).
@@ -5,13 +5,6 @@
5
5
  // else is flagged for review, never rewritten.
6
6
  export type MigrateAsset = { rel: string; template: string; oldShas: string[] }
7
7
  export const TEMPLATE_ASSETS: MigrateAsset[] = [
8
- {
9
- "rel": "clarify-before-code/spec.md",
10
- "template": "templates/presets/careful/.plugins/clarify-before-code/spec.md",
11
- "oldShas": [
12
- "a1b9c7e189df9b75a27005d381b43682d717ee6f"
13
- ]
14
- },
15
8
  {
16
9
  "rel": "core/idle/idle.sh",
17
10
  "template": "templates/spec/project/.plugins/core/idle/idle.sh",
@@ -196,7 +189,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
196
189
  },
197
190
  {
198
191
  "rel": "forge-link/spec.md",
199
- "template": "templates/spec/project/.plugins/forge-link/spec.md",
192
+ "template": "templates/spec/project/.plugins/prompts/forge-link/spec.md",
200
193
  "oldShas": [
201
194
  "2472c085e6f72364eea004ba1d1b1f8d6b4e13ac",
202
195
  "c86439c5a3fe6cb3ece2c40186da0f476d1c52d4"
@@ -204,11 +197,16 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
204
197
  },
205
198
  {
206
199
  "rel": "memory-hygiene/spec.md",
207
- "template": "templates/spec/project/.plugins/memory-hygiene/spec.md",
200
+ "template": "templates/spec/project/.plugins/prompts/memory-hygiene/spec.md",
208
201
  "oldShas": [
209
202
  "2b5313e770c7d2e857817adcf7bb72aecaeca45f"
210
203
  ]
211
204
  },
205
+ {
206
+ "rel": "prompts/spec.md",
207
+ "template": "templates/spec/project/.plugins/prompts/spec.md",
208
+ "oldShas": []
209
+ },
212
210
  {
213
211
  "rel": "regroup/spec.md",
214
212
  "template": "templates/spec/project/.plugins/regroup/spec.md",
@@ -220,7 +218,7 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
220
218
  },
221
219
  {
222
220
  "rel": "reproduce-before-fix/spec.md",
223
- "template": "templates/spec/project/.plugins/reproduce-before-fix/spec.md",
221
+ "template": "templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md",
224
222
  "oldShas": [
225
223
  "28eef30893a83b634900e43dfe729043c8f60ea8",
226
224
  "2d056b7344cbc0c4eb0dbeb0ba69ccee0e8b47db",
@@ -286,6 +284,9 @@ export const TEMPLATE_ASSETS: MigrateAsset[] = [
286
284
  ]
287
285
  // old-world template assets the shipped package no longer carries (recognized → reported, not replaced):
288
286
  export const RETIRED_ASSETS: Record<string, string[]> = {
287
+ "clarify-before-code/spec.md": [
288
+ "a1b9c7e189df9b75a27005d381b43682d717ee6f"
289
+ ],
289
290
  "core/harness-session-id/harness-session-id.sh": [
290
291
  "32b3b963ba6c2c659f0543e82a2a34128c4860bf",
291
292
  "626d7f3c56d6430c5dafb7713880a606b4ee8b03"
@@ -296,11 +297,11 @@ export const RETIRED_ASSETS: Record<string, string[]> = {
296
297
  "c05f182fc92fcd224f4c7953dc118e59665971aa",
297
298
  "c617fc05780f921c55280b68d9f55e60110f74eb"
298
299
  ],
299
- "core/stop-gate/yatsu.evals.ndjson": [ // dead-words-ok: the migrator names the old vocabulary it migrates from
300
+ "core/stop-gate/yatsu.evals.ndjson": [
300
301
  "268c06c27f3b2dc71da8c0aca4d0507a227829f9",
301
302
  "e681fe71b8519577c19bb4f13bc0f310fb92dc31"
302
303
  ],
303
- "core/stop-gate/yatsu.md": [ // dead-words-ok: the migrator names the old vocabulary it migrates from
304
+ "core/stop-gate/yatsu.md": [
304
305
  "f83f6b2406eb359beb1181bf0145558241806116"
305
306
  ],
306
307
  "e2e-review/spec.md": [
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Labels are node LEAF names, matched with the same de-collision rule the loader applies (specs.ts reId):
5
5
  // a returned id matches a label if it IS the label or ends with `_<label>` — so a bare leaf keeps matching
6
- // after collision-qualification renames it (e.g. `spec-scout` → `injected-context_spec-scout`). A label may
6
+ // after collision-qualification renames it (e.g. a colliding `probe` leaf → `a_probe`). A label may
7
7
  // also be written pre-qualified to pin one collision branch.
8
8
  import { execFileSync } from 'node:child_process'
9
9
  import { fileURLToPath } from 'node:url'
@@ -26,7 +26,7 @@ const CASES = [
26
26
  ['read-before-code', 'the one-shot nudge that makes an agent read its spec before touching code', ['spec-first']],
27
27
  ['hot-reload', 'zero-downtime backend reload without dropping connections', ['supervisor']],
28
28
  ['many-owners', 'can several specs own the same code file, and what happens if too many do?', ['governed-related']],
29
- ['active-spec-search', 'an injected sub-agent that searches specs for the agent, the spec analog of Explore', ['spec-scout']],
29
+ ['active-spec-search', 'an injected sub-agent that searches specs for the agent, the spec analog of Explore', ['spec-search']],
30
30
  ['declare-done', 'how does a worker declare it is done', ['state']],
31
31
  ]
32
32
 
@@ -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,7 +98,7 @@ 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. `.plugins_spec-scout`) stays a single token everywhere it is resolved.
101
+ // so a collision-qualified id (e.g. `.plugins_<id>`) stays a single token everywhere it is resolved.
101
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.
@@ -174,7 +175,7 @@ function claimMatcher(file: string): (cf: string) => boolean {
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
181
  // spec node(s) that REFERENCE a file (frontmatter `related:` — carries coverage, never drift, never eval freshness):
@@ -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
- const code = list(r.fm.code)
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
@@ -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)
@@ -302,11 +308,11 @@ 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 plugin is a FLAT direct child of a plugin root (`<root>/<name>/spec.md`)
306
- // that carries a `surface: system|command|hook|skill|agent` frontmatter field naming where it plugs in. There are no
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 of its root. BOTH plugin roots participate: `.plugins` (the instance — DIY dev-flow plugins) and
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
310
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).
@@ -354,13 +360,12 @@ function bundleFiles(dir: string): string[] {
354
360
  return out.sort()
355
361
  }
356
362
  // gather the preset nodes under a plugin root that declare `surface: <surface>`. The scan is RECURSIVE —
357
- // `surface` is a FIELD, not a path (the design's core tenet), so a plugin may live at ANY depth and a
358
- // grouping parent may itself be a plugin (e.g. `.plugins/core` is a `surface: system` contract whose CHILDREN
359
- // are `surface: hook` nodes). The field filter keeps it safe: a node only gathers if it declares THIS
360
- // surface, so descending past a matched node never double-counts (children carry a different surface). For
361
- // `system`/`command` the result is identical to the old one-level scan on the current tree — every existing
362
- // such node is a flat direct child and no nested node declares those surfaces — so the gather set (hence
363
- // the appended system prompt and the command dropdown) is byte-for-byte unchanged.
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.
364
369
  function loadSurface(surface: 'command' | 'system' | 'hook' | 'skill' | 'agent'): ConfigPreset[] {
365
370
  const out: ConfigPreset[] = []
366
371
  const visit = (nodeDir: string, name: string) => {
@@ -10,7 +10,7 @@ The CLI speaks ONE grammar: `spex <noun> <verb> [object] [flags]`. Six nouns —
10
10
 
11
11
  Four disciplines, non-negotiable:
12
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 (for a find-by-story question a keyword can't reach, dispatch the `spec-scout` agent). 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.
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
14
 
15
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
16
 
@@ -45,7 +45,7 @@ Then grow nodes under that spine:
45
45
  `.svelte`/`.css`, or the dashboard) is a blind spot until it carries a `eval.md` — so write one as you
46
46
  extract it: a **real user-path** scenario — a goal and the steps to reach it through the running app (never a
47
47
  bare render-check), covering a failure/empty/edge state — with a **description** of those steps and the
48
- **expected** zero-loss result. Frontend scenarios are measured by looking (YATU) — a screenshot filed with
48
+ **expected** zero-loss result. Frontend scenarios are measured by looking through the running product, as a real user would — a screenshot filed with
49
49
  `spex eval add <node> --image <png> --pass`. Backend nodes don't need one yet; run `spex eval lint` to
50
50
  list the frontend nodes still uncovered.
51
51
 
@@ -0,0 +1,20 @@
1
+ ---
2
+ title: prompts
3
+ status: active
4
+ hue: 110
5
+ desc: Grouping shelf for the auxiliary `surface: system` prompt contracts — single-body always-on prose. `core` is not a resident — the core contract subsystem is a direct `.plugins` child. A shelf, not a surface — routing stays field-driven.
6
+ ---
7
+ # prompts
8
+
9
+ The **auxiliary** `surface: system` prompt contracts live here: leaf plugins whose whole substance is one
10
+ prose body an agent must always carry — materialized (in name order, together with every other system
11
+ body) into the `<!-- spexcode -->` contract block — rather than a verb it invokes. Grouping them keeps
12
+ `.plugins/` legible at a glance: peripheral prompt contracts on this shelf; the invocable surfaces
13
+ (command/skill/agent) and `core` as flat children beside it.
14
+
15
+ The shelf boundary is **leafness, not surface**: a single-body prose contract shelves here, while a
16
+ contract that anchors its own subtree outranks the shelf — `core`, the contract subsystem whose children
17
+ are the `surface: hook` gates, is a *peer* of this shelf, never a resident.
18
+
19
+ This node is a **shelf, not a surface**: it declares no `surface` field and gathers nothing itself.
20
+ Discovery is recursive and field-driven, so residents plug in exactly as they would at the root.
@@ -6,8 +6,11 @@ desc: The instance home — this repo's DIY dev-flow plugins live here as skill-
6
6
  ---
7
7
  `.plugins/` is the **instance** of the plugin system: the concrete dev-flow plugins this repo ships for
8
8
  working in it. Each plugin is a skill-shaped node — its folder *is* the unit (a `spec.md` plus any
9
- co-located scripts) — living as a flat child of `.plugins/` and carrying a `surface: command|system` field
10
- that names where it plugs in.
9
+ co-located scripts) — carrying a `surface: command|system|…` field that names where it plugs in.
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/`, while `core` — the contract subsystem whose
12
+ children are the `surface: hook` gates — and invocable presets (command/skill/agent) are flat
13
+ children here.
11
14
 
12
15
  The launcher's system gather and the new-session dropdown read from here. Only **active** plugins
13
16
  gather: a `pending` node is declared intent, not yet an active plugin. The seed ships `core`
@@ -5,4 +5,4 @@ status: active
5
5
  hue: 280
6
6
  desc: Launch a supervisor agent that manages other agents from the main checkout to drive a goal to completion.
7
7
  ---
8
- You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code. **FIRST, read `<root>/CLAUDE.md` specifically its "Supervising the manager loop" section** that is your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize). Then drive the goal: decompose it into worker-sized tasks and dispatch one worker per independent task (`spex session new "<task>"` — give each ONLY its task; a task about one specific node mentions it as `[[<id>]]`, which only sets the branch name and board attribution; the session's real node links come from what it edits), monitor with `spex session watch`, review proposals with `spex session review <id>`, merge good ones with `git -C <root> merge --no-ff <branch>`, then close. Never let a worker self-merge; keep `spex spec lint` at 0 errors. To WAIT on a worker, POLL one-shot (`spex session review <id>` or `spex session ls` — both return immediately); never block on `spex session watch`, which STREAMS forever and will freeze your turn. One footgun that bites a fresh supervisor: before `spex session close <id>`, confirm the merge landed (`git -C <root> log -1` shows HEAD at the new merge commit) — closing an unmerged branch discards the work. Report progress as you go and when the goal is complete. Your goal follows:
8
+ You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code. **FIRST, read the project's agent notes (`<root>/CLAUDE.md` / `AGENTS.md`, including the materialized `<!-- spexcode -->` contract block)** for project-specific rules; the loop below (dispatch → monitor → review → merge → close, parallelizing independent tasks) is your playbook. Then drive the goal: decompose it into worker-sized tasks and dispatch one worker per independent task (`spex session new "<task>"` — give each ONLY its task; a task about one specific node mentions it as `[[<id>]]`, which only sets the branch name and board attribution; the session's real node links come from what it edits), monitor with `spex session watch`, review proposals with `spex session review <id>`, merge good ones with `git -C <root> merge --no-ff <branch>`, then close. Never let a worker self-merge; keep `spex spec lint` at 0 errors. To WAIT on a worker, POLL one-shot (`spex session review <id>` or `spex session ls` — both return immediately); never block on `spex session watch`, which STREAMS forever and will freeze your turn. One footgun that bites a fresh supervisor: before `spex session close <id>`, confirm the merge landed (`git -C <root> log -1` shows HEAD at the new merge commit) — closing an unmerged branch discards the work. Report progress as you go and when the goal is complete. Your goal follows:
@@ -12,7 +12,7 @@ made it. This node is the founding spec everything else hangs from — **rewrite
12
12
  your own project**, then grow child package/feature nodes beneath it (each its own directory with a
13
13
  `spec.md`).
14
14
 
15
- `.plugins/` holds the dev-flow plugins this instance ships — skill-shaped flat child nodes, each tagged
15
+ `.plugins/` holds the dev-flow plugins this instance ships — skill-shaped child nodes, each tagged
16
16
  with a `surface` field: a `surface: system` node folds into every launched agent's system prompt as
17
17
  always-on contract (the seed ships `core`), and a `surface: command` node is a prompt preset the
18
18
  new-session dropdown composes over target nodes (the seed ships `tidy`). Add, edit, or