spexcode 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/spec-cli/src/anchors.ts +300 -0
- package/spec-cli/src/cli.ts +11 -15
- package/spec-cli/src/git.ts +21 -13
- package/spec-cli/src/guide.ts +22 -5
- package/spec-cli/src/help.ts +19 -9
- package/spec-cli/src/index.ts +14 -0
- package/spec-cli/src/init.ts +5 -4
- package/spec-cli/src/lint.ts +55 -22
- package/spec-cli/src/localIssues.ts +19 -0
- package/spec-cli/src/migrate-table.ts +13 -12
- package/spec-cli/src/search.bench.mjs +2 -2
- package/spec-cli/src/specs.ts +19 -14
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +5 -2
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-C7Bzsv86.js → Dashboard-CTcH2eW9.js} +3 -3
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-D5iB4Ac2.js → FoldToggle-CVFbBpyW.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-CMFTsQhg.js → IssuesPage-kULjonqj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-DwuTKgdP.js → MobileApp-B0ZJju8K.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CBS5_cmK.js → SessionInterface-BRKJqU2U.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionWindow-CqAnjWfI.js → SessionWindow-CDhEL7wO.js} +7 -7
- package/spec-dashboard/dist/assets/{Settings-BW5f0OaW.js → Settings-BL6FV_8S.js} +1 -1
- package/spec-dashboard/dist/assets/{index-Cc26X4ce.css → index-DmQsNYKK.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Ce0wDyQS.js → index-DulGPk6A.js} +7 -7
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +50 -3
- package/spec-eval/src/evaltab.ts +11 -3
- package/spec-eval/src/humanok.ts +43 -0
- package/spec-eval/src/sidecar.ts +35 -9
- package/spec-cli/templates/presets/careful/.plugins/clarify-before-code/spec.md +0 -11
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +0 -2
- /package/spec-cli/templates/spec/project/.plugins/{forge-link → prompts/forge-link}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{memory-hygiene → prompts/memory-hygiene}/spec.md +0 -0
- /package/spec-cli/templates/spec/project/.plugins/{reproduce-before-fix → prompts/reproduce-before-fix}/spec.md +0 -0
package/spec-cli/src/lint.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { repoRoot,
|
|
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": [
|
|
300
|
+
"core/stop-gate/yatsu.evals.ndjson": [
|
|
300
301
|
"268c06c27f3b2dc71da8c0aca4d0507a227829f9",
|
|
301
302
|
"e681fe71b8519577c19bb4f13bc0f310fb92dc31"
|
|
302
303
|
],
|
|
303
|
-
"core/stop-gate/yatsu.md": [
|
|
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. `
|
|
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-
|
|
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
|
|
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,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. `.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
358
|
-
// grouping
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
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
|
|
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
|
|
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) —
|
|
10
|
-
|
|
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`
|
|
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
|
|
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
|