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/issues.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// stored — the local git store ([[local-issues]]) or a remote forge (spec-forge) — is a per-issue property
|
|
4
4
|
// (`store`), not a project mode: a project holds both at once, mixed. This module owns the core type, the
|
|
5
5
|
// forge→Issue translation (the ONLY place a host's node-naming conventions become `nodes[]` — platform
|
|
6
|
-
// differences stay at the adapter boundary), the merged read every surface consumes (CLI `spex
|
|
6
|
+
// differences stay at the adapter boundary), the merged read every surface consumes (CLI `spex issue ls`,
|
|
7
7
|
// GET /api/issues, the board fold), the STORE-ROUTED reply/close verbs, and the CLI itself. Content writes are
|
|
8
8
|
// owned per store: local ones live in localIssues.ts; a forge write goes through the driver's write verbs
|
|
9
9
|
// (createIssue/createComment/closeIssue — the driver stays the only network toucher; the tracer stays read-only).
|
|
@@ -26,7 +26,7 @@ export type Reply = {
|
|
|
26
26
|
body: string
|
|
27
27
|
rid?: string // stable per-remark id; a reply is a remark iff this is set. Ref: `<thread-id>#<rid>`
|
|
28
28
|
targetCodeSha?: string // the reading/codeSha the remark was authored against (worktree HEAD by default)
|
|
29
|
-
resolved?: boolean // the ONE mutable teeth bit — false at author, true after a deliberate `spex resolve`
|
|
29
|
+
resolved?: boolean // the ONE mutable teeth bit — false at author, true after a deliberate `spex remark resolve`
|
|
30
30
|
resolvedAt?: string
|
|
31
31
|
resolvedBy?: string
|
|
32
32
|
}
|
|
@@ -41,7 +41,7 @@ export type Issue = {
|
|
|
41
41
|
created: string
|
|
42
42
|
body: string
|
|
43
43
|
replies: Reply[]
|
|
44
|
-
evidence: string[] //
|
|
44
|
+
evidence: string[] // content-addressed evidence hashes — the typed cross-node finding reference
|
|
45
45
|
url?: string // a forge permalink; a local issue has none
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -64,7 +64,7 @@ function forgeIssueBody(concern: string, body: string | undefined, nodes: string
|
|
|
64
64
|
return [
|
|
65
65
|
(body || `(no detail given — ${concern})`).trim(),
|
|
66
66
|
nodes.length ? `Spec: ${nodes.join(', ')}` : '',
|
|
67
|
-
evidence.length ? `Evidence: ${evidence.join(', ')} (
|
|
67
|
+
evidence.length ? `Evidence: ${evidence.join(', ')} (evidence content hashes)` : '',
|
|
68
68
|
].filter(Boolean).join('\n\n')
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -137,7 +137,7 @@ export function fromForge(slice: ForgeSlice, nodeIds: string[]): Issue[] {
|
|
|
137
137
|
// freshness — the server passes the resident cache's state (instant, background reconcile), the CLI a
|
|
138
138
|
// live pull — so the merge itself stays pure. Eval-remark threads are SPLIT OUT read-time (isEvalConcern):
|
|
139
139
|
// they are the eval scoreboard's data, not issues, so every issue surface this feeds — the Threads tab, the
|
|
140
|
-
// board issue badge, the `spex
|
|
140
|
+
// board issue badge, the `spex issue ls` drain — is free of them by construction (they reach the EVAL side
|
|
141
141
|
// through loadEvalRemarkTracks / the reading overlay instead).
|
|
142
142
|
export function mergedIssues(forge: ForgeSlice | null, nodeIds: string[]): Issue[] {
|
|
143
143
|
const remote = forge ? fromForge(forge, nodeIds) : []
|
|
@@ -147,7 +147,7 @@ export function mergedIssues(forge: ForgeSlice | null, nodeIds: string[]): Issue
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
// @@@ createIssue - the ONE creation port, store-routed ([[issues]]): the dashboard's New form
|
|
150
|
-
// (POST /api/issues) and `spex
|
|
150
|
+
// (POST /api/issues) and `spex issue open [--store <store>]` run this SAME routine. Default local commits
|
|
151
151
|
// to the trunk store; a forge store creates the REAL forge issue through that store's driver, its body
|
|
152
152
|
// carrying the `Spec: <nodes>` marker so the existing tracer read links it straight back — no promote
|
|
153
153
|
// round-trip needed when the concern is born forge-visible.
|
|
@@ -197,7 +197,7 @@ export async function promote(id: string, opts: { author?: string } = {}): Promi
|
|
|
197
197
|
const body = [
|
|
198
198
|
t.body,
|
|
199
199
|
t.nodes.length ? `\nSpec: ${t.nodes.join(', ')}` : '',
|
|
200
|
-
t.evidence.length ? `\nEvidence: ${t.evidence.join(', ')} (
|
|
200
|
+
t.evidence.length ? `\nEvidence: ${t.evidence.join(', ')} (evidence content hashes)` : '',
|
|
201
201
|
`\n---\nPromoted from the local issue \`${id}\` (opened by ${t.by} @ ${t.created}; promoted by ${author}).`,
|
|
202
202
|
].filter(Boolean).join('\n')
|
|
203
203
|
const { number, url } = await driver.createIssue({ title: t.concern, body })
|
|
@@ -254,23 +254,87 @@ const fl = (args: string[], name: string): string | undefined => {
|
|
|
254
254
|
}
|
|
255
255
|
const hasFlag = (args: string[], name: string) => args.includes(`--${name}`)
|
|
256
256
|
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
257
|
+
// the CLI's live forge pull — `ls` and `show` own their freshness (a live driver read), degrading LOUDLY
|
|
258
|
+
// to local-only (one stderr note) when the forge is unreachable: local reading never hostages on a network.
|
|
259
|
+
async function liveForgeSlice(verb: string): Promise<ForgeSlice | null> {
|
|
260
|
+
try {
|
|
261
|
+
const host = resolveForgeHost()
|
|
262
|
+
const driver = forgeDriverFor(host)
|
|
263
|
+
if (!driver) throw new Error(`no driver for this repo's forge host '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')})`)
|
|
264
|
+
const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()])
|
|
265
|
+
return { host: driver.host, state: { issues, prs } }
|
|
266
|
+
} catch (e) {
|
|
267
|
+
console.error(`spex issue ${verb}: forge unreachable — local only (${e instanceof Error ? e.message.split('\n')[0] : e})`)
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// the single-issue read behind `spex issue show` AND `GET /api/issues/:id` — find the thread in the SAME
|
|
273
|
+
// merged, eval-remark-free read every issue surface consumes (never a second lookup path: an eval-remark
|
|
274
|
+
// thread is not an issue, so `show` can't see one either). A local id needs no forge slice; a forge id
|
|
275
|
+
// (`<host>#<n>`) reads from the caller-supplied slice (live pull on the CLI, resident cache on the server).
|
|
276
|
+
export function findIssue(id: string, forge: ForgeSlice | null, nodeIds: string[]): Issue | undefined {
|
|
277
|
+
return mergedIssues(id.includes('#') ? forge : null, nodeIds).find((i) => i.id === id)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function renderIssue(t: Issue): string {
|
|
281
|
+
const L: string[] = []
|
|
282
|
+
L.push(`${t.concern} [${t.id}]`)
|
|
283
|
+
L.push(` ${[t.store, t.status, t.nodes.length ? `re: ${t.nodes.join(', ')}` : '', t.by ? `by ${t.by}` : '', t.created].filter(Boolean).join(' · ')}`)
|
|
284
|
+
if (t.url) L.push(` ${t.url}`)
|
|
285
|
+
if (t.evidence.length) L.push(` evidence: ${t.evidence.join(', ')}`)
|
|
286
|
+
L.push('', t.body)
|
|
287
|
+
for (const r of t.replies) {
|
|
288
|
+
L.push('', `── ${isRemark(r) ? `remark ${t.id}#${r.rid}${r.resolved ? ` (resolved by ${r.resolvedBy})` : ' (unresolved)'}` : 'reply'}: ${r.by} @ ${r.at} ──`)
|
|
289
|
+
L.push(r.body)
|
|
290
|
+
}
|
|
291
|
+
return L.join('\n')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// `spex issue <verb>` — the ONE issue surface, a noun drawer ([[cli-surface]]). `ls` is THE read over
|
|
295
|
+
// every store: the drain view a supervisor/human works from, `[--node id] [--store local|<host>] [--all]
|
|
296
|
+
// [--json]`; `show <id>` is the single-thread detail (the same read GET /api/issues/:id serves). The
|
|
297
|
+
// write verbs (open|reply — localIssues.ts) are store-routed (`open --store <host>` / a `<host>#<n>` id
|
|
298
|
+
// go through the driver); `close` is the store-routed lifecycle verb (the SAME closeIssue the dashboard's
|
|
299
|
+
// Close button calls); `promote` is the one cross-store verb; `links` is the read-only forge→spec trace
|
|
300
|
+
// (spec-forge). The list imposes NO salience ranking — replies are a signal the drain WEIGHS by judgment,
|
|
301
|
+
// never an automatic priority order. The forge slice is a LIVE pull that degrades loudly to local-only.
|
|
302
|
+
// (`nudge` left this drawer for `spex internal nudge` — only the post-merge hook calls it; the old
|
|
303
|
+
// on|off|status toggle verbs are gone — the switch is the `issues.enabled` settings key.)
|
|
266
304
|
export async function runIssues(args: string[]): Promise<number> {
|
|
305
|
+
// the drawer's READ verbs (ls/show) surface a store failure exactly as the writes do
|
|
306
|
+
// ([[issues-store-rename]]'s both-exist teeth): one clean `spex issue: <message>` line + exit 1, never a
|
|
307
|
+
// raw stack — the message carries the repair, the stack is internals. The verbs that already catch with
|
|
308
|
+
// a more specific prefix (open/reply/close/promote) return before this guard ever sees their errors.
|
|
309
|
+
try { return await issueVerbs(args) }
|
|
310
|
+
catch (e) { console.error(`spex issue: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
311
|
+
}
|
|
312
|
+
async function issueVerbs(args: string[]): Promise<number> {
|
|
267
313
|
if (ISSUE_WRITE_SUBS.has(args[0])) return runIssueWrite(args)
|
|
314
|
+
if (args[0] === 'on' || args[0] === 'off' || args[0] === 'status') {
|
|
315
|
+
// v0.3.0 signpost — report the new home, never run ([[cli-surface]]: a removed spelling only points).
|
|
316
|
+
console.error(`spex: \`spex issue ${args[0]}\` was removed in v0.3.0 — the switch is the \`issues.enabled\` key in spexcode.json (edit the JSON; \`spex guide settings\` documents it, \`spex doctor\` reports its state)`)
|
|
317
|
+
return 2
|
|
318
|
+
}
|
|
319
|
+
if (args[0] === 'show') {
|
|
320
|
+
const id = args[1]
|
|
321
|
+
if (!id || id.startsWith('--')) { console.error('usage: spex issue show <issue-id> [--json] (a local id, or a forge id like github#12)'); return 2 }
|
|
322
|
+
const nodeIds = loadSpecsLite().map((s) => s.id)
|
|
323
|
+
const t = findIssue(id, id.includes('#') ? await liveForgeSlice('show') : null, nodeIds)
|
|
324
|
+
if (!t) { console.error(`spex issue show: no issue '${id}' (see \`spex issue ls --all\`)`); return 1 }
|
|
325
|
+
console.log(hasFlag(args, 'json') ? JSON.stringify(t, null, 2) : renderIssue(t))
|
|
326
|
+
return 0
|
|
327
|
+
}
|
|
328
|
+
if (args[0] === 'links') {
|
|
329
|
+
const { runIssueLinks } = await import('../../spec-forge/src/cli.js')
|
|
330
|
+
return runIssueLinks(args.slice(1))
|
|
331
|
+
}
|
|
268
332
|
if (args[0] === 'close') {
|
|
269
333
|
// the CLI leg of the ONE close verb ([[issues]] closeIssue — the same routing POST /api/issues/:id/close
|
|
270
334
|
// runs): a local id resolves the thread `landed`, a forge id (`<host>#<n>`) closes the remote issue
|
|
271
335
|
// through the driver. Lifecycle on the issue object, never node state.
|
|
272
336
|
const id = args[1]
|
|
273
|
-
if (!id || id.startsWith('--')) { console.error('usage: spex
|
|
337
|
+
if (!id || id.startsWith('--')) { console.error('usage: spex issue close <issue-id> (a local id, or a forge id like github#12)'); return 2 }
|
|
274
338
|
try {
|
|
275
339
|
const r = await closeIssue(id)
|
|
276
340
|
console.log(r.store === 'local'
|
|
@@ -278,37 +342,29 @@ export async function runIssues(args: string[]): Promise<number> {
|
|
|
278
342
|
: `closed '${id}' on ${r.store}${r.url ? ` ${r.url}` : ''}`)
|
|
279
343
|
return 0
|
|
280
344
|
} catch (e) {
|
|
281
|
-
console.error(`spex
|
|
345
|
+
console.error(`spex issue close: ${e instanceof Error ? e.message : e}`)
|
|
282
346
|
return 1
|
|
283
347
|
}
|
|
284
348
|
}
|
|
285
349
|
if (args[0] === 'promote') {
|
|
286
350
|
const id = args[1]
|
|
287
|
-
if (!id || id.startsWith('--')) { console.error('usage: spex
|
|
351
|
+
if (!id || id.startsWith('--')) { console.error('usage: spex issue promote <local-issue-id>'); return 2 }
|
|
288
352
|
try {
|
|
289
353
|
const r = await promote(id)
|
|
290
354
|
console.log(`promoted '${id}' → ${r.host}#${r.number} ${r.url}\n local thread closed landed (permalink recorded in its reply trail)`)
|
|
291
355
|
return 0
|
|
292
356
|
} catch (e) {
|
|
293
|
-
console.error(`spex
|
|
357
|
+
console.error(`spex issue promote: ${e instanceof Error ? e.message : e}`)
|
|
294
358
|
return 1
|
|
295
359
|
}
|
|
296
360
|
}
|
|
297
|
-
if (args[0]
|
|
298
|
-
console.error(`spex
|
|
361
|
+
if (args[0] !== 'ls') {
|
|
362
|
+
console.error(`spex issue: unknown verb '${args[0]}' — ls | show | open | reply | close | promote | links (spex help issue)`)
|
|
299
363
|
return 2
|
|
300
364
|
}
|
|
365
|
+
args = args.slice(1)
|
|
301
366
|
const nodeIds = loadSpecsLite().map((s) => s.id)
|
|
302
|
-
|
|
303
|
-
try {
|
|
304
|
-
const host = resolveForgeHost()
|
|
305
|
-
const driver = forgeDriverFor(host)
|
|
306
|
-
if (!driver) throw new Error(`no driver for this repo's forge host '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')})`)
|
|
307
|
-
const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()])
|
|
308
|
-
forge = { host: driver.host, state: { issues, prs } }
|
|
309
|
-
} catch (e) {
|
|
310
|
-
console.error(`spex issues: forge unreachable — listing local only (${e instanceof Error ? e.message.split('\n')[0] : e})`)
|
|
311
|
-
}
|
|
367
|
+
const forge = await liveForgeSlice('ls')
|
|
312
368
|
let issues = mergedIssues(forge, nodeIds)
|
|
313
369
|
const node = fl(args, 'node')
|
|
314
370
|
const store = fl(args, 'store')
|
|
@@ -325,6 +381,6 @@ export async function runIssues(args: string[]): Promise<number> {
|
|
|
325
381
|
if (p.replies.length) console.log(` ${p.replies.length} reply(ies) in thread`)
|
|
326
382
|
if (p.url) console.log(` ${p.url}`)
|
|
327
383
|
}
|
|
328
|
-
if (!issuesEnabled()) console.log('\n(the issues workflow is OFF — `
|
|
384
|
+
if (!issuesEnabled()) console.log('\n(the issues workflow is OFF — set `"issues": { "enabled": true }` in spexcode.json to re-enable writes/nudges)')
|
|
329
385
|
return 0
|
|
330
386
|
}
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -9,7 +9,7 @@ type Config = {
|
|
|
9
9
|
main?: string // path to the source-of-truth checkout (default: the `main` worktree)
|
|
10
10
|
mainBranch?: string // source-of-truth BRANCH worktrees fork from (default: auto-detected — see mainBranch())
|
|
11
11
|
branchPrefix?: string // how a branch names its node (default: "node/")
|
|
12
|
-
preset?: string // the SELECTED init preset — which cumulative .
|
|
12
|
+
preset?: string // the SELECTED init preset — which cumulative .plugins tier `spex init` seeds (default 'default'; seed-time only, no launcher gate; read by init.ts; see [[init-preset]])
|
|
13
13
|
// RETIRED ([[residence]]) — the old three-word footprint vote. Materialized artifacts carry no facts and are never
|
|
14
14
|
// tracked now (one residence behavior: the per-clone exclude, plus the content filter for a mixed
|
|
15
15
|
// contract file), so the field is IGNORED with a loud non-fatal notice (materialize's retiredAxisNotice);
|
|
@@ -47,7 +47,7 @@ type Config = {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
issues?: {
|
|
50
|
-
enabled?: boolean // the [[local-issues]] issues-workflow on/off switch (default ON). OFF silences the post-merge nudge + hides the dashboard view; flip
|
|
50
|
+
enabled?: boolean // the [[local-issues]] issues-workflow on/off switch (default ON). OFF silences the post-merge nudge + hides the dashboard view; flip by editing this key (no CLI toggle verb — v0.3.0). A legacy `proposals.enabled` is NOT read; `spex doctor` reports it.
|
|
51
51
|
}
|
|
52
52
|
forge?: {
|
|
53
53
|
host?: string // explicit forge host id ('github'|'gitlab'|…) overriding the origin-remote derivation ([[forge-host]] — read by spec-forge drivers.ts resolveForgeHost, not here). A project fact → committed spexcode.json.
|
|
@@ -130,7 +130,7 @@ export function mainCheckout(proj?: string): string {
|
|
|
130
130
|
// agents in one folder never clobber, and grouped PER PROJECT (mirroring Claude's ~/.claude/projects/<enc>/)
|
|
131
131
|
// so the board enumerates ONE directory. This is the single seam that knows where the store sits; sessions.ts
|
|
132
132
|
// and the shell hooks resolve through the SAME scheme (the hooks reimplement it in bash, so any change here
|
|
133
|
-
// must be mirrored in .
|
|
133
|
+
// must be mirrored in .plugins/core/*/). SPEXCODE_HOME overrides the root for test isolation.
|
|
134
134
|
export function spexcodeHome(): string {
|
|
135
135
|
return process.env.SPEXCODE_HOME || join(homedir(), '.spexcode')
|
|
136
136
|
}
|
|
@@ -151,7 +151,7 @@ export function runtimeRoot(proj?: string): string {
|
|
|
151
151
|
return join(spexcodeHome(), 'projects', encodeProject(dirname(gcd)))
|
|
152
152
|
}
|
|
153
153
|
// the per-WORKTREE materialize slot — <runtime>/trees/<enc(worktree-toplevel)> — holding the materialize
|
|
154
|
-
// products that are a pure function of ONE tree's .
|
|
154
|
+
// products that are a pure function of ONE tree's .plugins (hooks-manifest, content-hash, plugin-folders).
|
|
155
155
|
// Slotted per tree exactly like sessions/<id> is slotted per session: the old single global file made the
|
|
156
156
|
// last-materialized tree win, so dispatch ran tree A's compiled hook set inside tree B's sessions
|
|
157
157
|
// ([[hook-dispatch]]). Key = the sessions encodeProject transform over `rev-parse --show-toplevel`, the
|
|
@@ -198,7 +198,7 @@ export type RawRecord = {
|
|
|
198
198
|
// `SPEXCODE_SESSION_ID`.
|
|
199
199
|
// Claude is UNCHANGED: its `sessionEnvVar` (CLAUDE_CODE_SESSION_ID) already EQUALS its record id, so tier (1)
|
|
200
200
|
// resolves to that very id — the same value `SPEXCODE_SESSION_ID` would have returned; there is no shared
|
|
201
|
-
// app-server to contaminate it. No worktree fallback. (sessions.ts's `ownSessionId` delegates here; spec-
|
|
201
|
+
// app-server to contaminate it. No worktree fallback. (sessions.ts's `ownSessionId` delegates here; spec-eval
|
|
202
202
|
// reads it to resolve the current node.)
|
|
203
203
|
export function envSessionId(): string | null {
|
|
204
204
|
for (const h of HARNESSES) {
|
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,9 +14,8 @@ 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
|
-
scenarioTags: string[] // the closed vocabulary
|
|
18
|
+
scenarioTags: string[] // the closed vocabulary an eval scenario's `tags:` must draw from; extend it to mint a new tag
|
|
19
19
|
}
|
|
20
20
|
const DEFAULT_CONFIG: LintConfig = {
|
|
21
21
|
governedRoots: ['spec-dashboard/src', 'spec-cli/src'],
|
|
@@ -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
|
}
|
|
@@ -55,7 +54,7 @@ export function normalizeConfig(cfg: LintConfig): LintConfig {
|
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
// the source-file matcher, built from the configurable `sourceExtensions` knob. Coverage uses it to decide
|
|
58
|
-
// which tracked files must be governed;
|
|
57
|
+
// which tracked files must be governed; eval lint's `eval-coverage` reuses THE SAME knob so ONE setting
|
|
59
58
|
// defines "source" for both coverage axes — a non-web project (Rust/Go/Python .rs/.go/.py) sets it once and
|
|
60
59
|
// both the coverage warning and the loss-signal blind-spot check follow, with no second web-only allowlist.
|
|
61
60
|
export const sourceExtRe = (extensions: string[]) => new RegExp(`\\.(${extensions.join('|')})$`)
|
|
@@ -140,7 +139,7 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
140
139
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
|
|
141
140
|
owners.set(f, [...(owners.get(f) ?? []), s.id])
|
|
142
141
|
}
|
|
143
|
-
// one-govern: a node is source of truth for at most ONE file, so drift/
|
|
142
|
+
// one-govern: a node is source of truth for at most ONE file, so drift/eval/ack have a single
|
|
144
143
|
// unambiguous subject (see [[governed-related]]). >1 is a defect — pick the true subject, demote the
|
|
145
144
|
// rest to related. ERROR (the node-side twin of too-many-owners' file-side bound). 0 is fine.
|
|
146
145
|
if (s.code.length > 1)
|
|
@@ -148,7 +147,7 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
148
147
|
}
|
|
149
148
|
// a file is COVERED if any node GOVERNS (code:) or merely REFERENCES (related:) it; integrity covers both.
|
|
150
149
|
// `related:` is the coverage net: govern is a sharp ideally-one-file pointer, so most files are reached by
|
|
151
|
-
// related, not govern (see [[governed-related]]). It carries coverage but never drift
|
|
150
|
+
// related, not govern (see [[governed-related]]). It carries coverage but never drift, never eval freshness.
|
|
152
151
|
const claimed = new Set<string>(owners.keys())
|
|
153
152
|
for (const s of specs) for (const f of s.related) {
|
|
154
153
|
if (!existsSync(join(root, f)))
|
|
@@ -156,6 +155,71 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
156
155
|
claimed.add(f)
|
|
157
156
|
}
|
|
158
157
|
|
|
158
|
+
// id-format: a node id (its leaf dir basename) passes an EXACT per-character whitelist — an ascii char
|
|
159
|
+
// must be [a-z0-9-]; a non-ascii char must be a unicode letter/number (judged on NFC, the mint's
|
|
160
|
+
// canonical form) — and is UNIQUE tree-wide (ERROR). This is THE id vocabulary, defined once (the
|
|
161
|
+
// spec-lint node's rule table) and referenced by [[mentions]] / [[id-url-safe]]: CJK and every other
|
|
162
|
+
// letter script is first-class, exactly what the script-agnostic resolve machinery already accepts.
|
|
163
|
+
// Everything else is forbidden by construction, no heuristics — space, '/', uppercase Latin (lowercase
|
|
164
|
+
// is the Latin norm), control chars, and '_' (reserved as the mint's parent-qualification join; a '_'
|
|
165
|
+
// inside a basename would make that join ambiguous). One optional leading dot is allowed — the
|
|
166
|
+
// reflexive plugin root `.plugins` is dot-prefixed by design. Uniqueness is what keeps the leaf THE id:
|
|
167
|
+
// on a collision the mint must parent-qualify with `_`, so every surface suddenly speaks a longer id
|
|
168
|
+
// than the dir name — legal to the machinery, illegible to people.
|
|
169
|
+
const ID_RE = /^\.?(?:[a-z0-9-]|(?![\x00-\x7F])[\p{L}\p{N}])+$/u
|
|
170
|
+
const leafOf = (p: string) => { const segs = p.split('/'); return segs[segs.length - 2] }
|
|
171
|
+
const byLeaf = new Map<string, string[]>()
|
|
172
|
+
for (const s of specs) {
|
|
173
|
+
const leaf = leafOf(s.path)
|
|
174
|
+
byLeaf.set(leaf, [...(byLeaf.get(leaf) ?? []), s.path])
|
|
175
|
+
if (!ID_RE.test(leaf.normalize('NFC')))
|
|
176
|
+
out.push({ level: 'error', rule: 'id-format', spec: s.id, msg: `node dir '${leaf}' is not a valid id — each char is ascii [a-z0-9-] or a non-ascii unicode letter/number (one optional leading dot); space, '/', '_', uppercase Latin and control chars are forbidden; rename the directory` })
|
|
177
|
+
}
|
|
178
|
+
for (const [leaf, paths] of byLeaf) {
|
|
179
|
+
if (paths.length > 1)
|
|
180
|
+
out.push({ level: 'error', rule: 'id-format', msg: `leaf id '${leaf}' names ${paths.length} nodes [${paths.map((p) => p.replace(/\/spec\.md$/, '')).join(', ')}] — a leaf id is unique tree-wide; rename all but one` })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// confusable-id: two leaf ids one edit apart read as the same word (WARN — a typo in either reaches a
|
|
184
|
+
// real, wrong node). Deliberately conservative: distance exactly 1, so hierarchy naming like
|
|
185
|
+
// graph/graph-delivery (a whole suffix apart) and verb pairs like evidence-put/evidence-get (distance 2)
|
|
186
|
+
// never warn — better to miss a borderline pair than to nag legitimate siblings. Distance is measured
|
|
187
|
+
// in CODE POINTS, script-agnostic: a CJK pair one character apart (节点/结点 — a classic homophone
|
|
188
|
+
// IME slip) warns like an ascii pair, an astral char counts as one edit (not two surrogate units), and
|
|
189
|
+
// a pure-CJK id can never sit one edit from a pure-ascii one.
|
|
190
|
+
const lev1 = (a: string, b: string): boolean => {
|
|
191
|
+
const A = [...a], B = [...b]
|
|
192
|
+
if (Math.abs(A.length - B.length) > 1 || a === b) return false
|
|
193
|
+
let i = 0
|
|
194
|
+
while (i < A.length && i < B.length && A[i] === B[i]) i++
|
|
195
|
+
const rest = (x: string[], k: number) => x.slice(k).join('')
|
|
196
|
+
return rest(A, i + 1) === rest(B, i + 1) || rest(A, i + 1) === rest(B, i) || rest(A, i) === rest(B, i + 1)
|
|
197
|
+
}
|
|
198
|
+
const leaves = [...byLeaf.keys()]
|
|
199
|
+
for (let i = 0; i < leaves.length; i++) for (let j = i + 1; j < leaves.length; j++) {
|
|
200
|
+
if (lev1(leaves[i], leaves[j]))
|
|
201
|
+
out.push({ level: 'warn', rule: 'confusable-id', msg: `leaf ids '${leaves[i]}' and '${leaves[j]}' are one edit apart — easily confused; if they are distinct concepts, rename one to read apart` })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// mention: a `[[id]]` in a body must name a real node (ERROR — a dangling mention is a broken edge in
|
|
205
|
+
// the very graph the tree exists to keep honest). Checked against the SAME minted ids every other
|
|
206
|
+
// surface resolves ([[id-url-safe]]). Prose only: a fenced block or inline `code span` is sample text
|
|
207
|
+
// (`[[node]]`, `[[<id>]]` placeholders live there), not a reference.
|
|
208
|
+
const idSet = new Set(specs.map((s) => s.id))
|
|
209
|
+
const MENTION_RE = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/gu
|
|
210
|
+
for (const s of specs) {
|
|
211
|
+
let inFence = false
|
|
212
|
+
for (const rawLine of s.body.split('\n')) {
|
|
213
|
+
if (/^\s*```/.test(rawLine)) { inFence = !inFence; continue }
|
|
214
|
+
if (inFence) continue
|
|
215
|
+
const line = rawLine.replace(/`[^`]*`/g, '')
|
|
216
|
+
for (const m of line.matchAll(MENTION_RE)) {
|
|
217
|
+
if (!idSet.has(m[1]))
|
|
218
|
+
out.push({ level: 'error', rule: 'mention', spec: s.id, msg: `'${s.id}' mentions [[${m[1]}]] — no such node; retarget or drop it (backtick it if it is sample text)` })
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
159
223
|
// living: a spec body describes the node's CURRENT intent — it is not a changelog. Version history
|
|
160
224
|
// (every content commit, its reason/session/line-diff) is read from git and shown in the dashboard's
|
|
161
225
|
// recent/history tabs, so a `## vN`-style heading in the body is duplicated, drift-prone state.
|
|
@@ -212,6 +276,59 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
212
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:).` })
|
|
213
277
|
}
|
|
214
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
|
+
|
|
215
332
|
// drift: a governed file has commits NOT yet reflected in its spec. Judged by true git ancestry —
|
|
216
333
|
// loadSpecs computes `driftFiles` via driftFor() over the one cached driftIndex walk (git.ts): a
|
|
217
334
|
// commit to the file counts iff it is NOT reachable from the spec's latest version (in-memory
|
|
@@ -224,7 +341,7 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
224
341
|
|
|
225
342
|
// related drift: the SOFT tier ([[governed-related]]). A referenced file moved ahead of the node's
|
|
226
343
|
// version — a nudge that a dependency shifted. Same ancestry basis as govern drift, but WARN-only,
|
|
227
|
-
// never reaching the commit gate (driftGate reads govern) or
|
|
344
|
+
// never reaching the commit gate (driftGate reads govern) or eval freshness. It is COMMON (shared substrate and
|
|
228
345
|
// faces change often without re-versioning every referrer), so per-file it is a wall; like
|
|
229
346
|
// too-many-owners it collapses to ONE summary line, with the per-file detail riding the board
|
|
230
347
|
// (relatedDriftFiles). It stays a soft edge, never a per-file interruption.
|
|
@@ -233,7 +350,7 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
233
350
|
const byNode = new Map<string, number>()
|
|
234
351
|
for (const d of rd) byNode.set(d.id, (byNode.get(d.id) ?? 0) + 1)
|
|
235
352
|
const worst = [...byNode].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, n]) => `${id}(${n})`).join(', ')
|
|
236
|
-
out.push({ level: 'warn', rule: 'related-drift', msg: `${rd.length} related file(s) across ${byNode.size} node(s) drifted ahead of their spec (SOFT — a dependency shifted, worth a glance; never blocks, no ack, no
|
|
353
|
+
out.push({ level: 'warn', rule: 'related-drift', msg: `${rd.length} related file(s) across ${byNode.size} node(s) drifted ahead of their spec (SOFT — a dependency shifted, worth a glance; never blocks, no ack, no eval staleness). Most: ${worst}` })
|
|
237
354
|
}
|
|
238
355
|
|
|
239
356
|
return out
|
|
@@ -242,13 +359,13 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
242
359
|
export const DRIFT_GUIDANCE = `DRIFT — a governed file has moved ahead of its spec. A CHECKPOINT, not a chore: find WHERE the truth
|
|
243
360
|
broke along raw intent → expanded spec → code: link → code structure → implementation, fix THAT layer.
|
|
244
361
|
|
|
245
|
-
Inspect: spex lint which files, against which spec
|
|
362
|
+
Inspect: spex spec lint which files, against which spec
|
|
246
363
|
the node's spec.md its raw source + expanded spec
|
|
247
364
|
git diff $(git log -1 --format=%H -- <spec.md>)..HEAD -- <file> the code delta since the spec
|
|
248
365
|
|
|
249
366
|
Diagnose, then apply the one honest remedy:
|
|
250
367
|
• contract changed → rewrite the spec body to the new intent, commit it (re-versions the node)
|
|
251
|
-
• only mechanics changed → spex ack <node> "checked — spec still valid" (give a real reason)
|
|
368
|
+
• only mechanics changed → spex spec ack <node> "checked — spec still valid" (give a real reason)
|
|
252
369
|
• implementation is WRONG → the spec is right; fix the CODE back toward it, then ack
|
|
253
370
|
• wrong code: link → the node shouldn't own this file (or owns it too broadly); fix frontmatter
|
|
254
371
|
• expanded spec ≠ raw → the spec drifted from human intent; fix the expanded spec to serve the raw
|
|
@@ -256,22 +373,3 @@ Diagnose, then apply the one honest remedy:
|
|
|
256
373
|
maps to a node, or file an issue and link it (defer honestly)
|
|
257
374
|
|
|
258
375
|
Never patch. A reasoned ack or a real fix are recorded and re-judged at review; a blind ack is a lie.`
|
|
259
|
-
|
|
260
|
-
// commit-local: an empty staged index (CI, audit) → no blockers, drift stays advisory; non-empty → block
|
|
261
|
-
// only when an OWN staged file belongs to a node already >= driftErrorThreshold behind. Sub-threshold drift
|
|
262
|
-
// on a touched node is returned for an advisory nudge; the backlog on untouched nodes never blocks.
|
|
263
|
-
export async function driftGate(): Promise<{ blocked: string[]; touched: { id: string; drift: number }[]; threshold: number }> {
|
|
264
|
-
const root = repoRoot()
|
|
265
|
-
const cfg = loadConfig(root)
|
|
266
|
-
const staged = stagedFiles(root)
|
|
267
|
-
if (!staged.length) return { blocked: [], touched: [], threshold: cfg.driftErrorThreshold }
|
|
268
|
-
const specs = await loadSpecs()
|
|
269
|
-
const owners = new Map<string, string[]>()
|
|
270
|
-
for (const s of specs) for (const f of s.code) owners.set(f, [...(owners.get(f) ?? []), s.id])
|
|
271
|
-
const byId = new Map(specs.map((s) => [s.id, s]))
|
|
272
|
-
const ids = new Set<string>()
|
|
273
|
-
for (const f of staged) for (const o of owners.get(f) ?? []) ids.add(o)
|
|
274
|
-
const touched = [...ids].map((id) => byId.get(id)!).filter((s) => s && s.drift > 0)
|
|
275
|
-
.map((s) => ({ id: s.id, drift: s.drift })).sort((a, b) => b.drift - a.drift)
|
|
276
|
-
return { blocked: touched.filter((t) => t.drift >= cfg.driftErrorThreshold).map((t) => t.id), touched, threshold: cfg.driftErrorThreshold }
|
|
277
|
-
}
|