spexcode 0.2.7 → 0.3.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 (97) hide show
  1. package/README.md +56 -44
  2. package/package.json +3 -3
  3. package/spec-cli/bin/spex.mjs +2 -2
  4. package/spec-cli/hooks/dispatch.sh +1 -1
  5. package/spec-cli/hooks/harness.sh +26 -6
  6. package/spec-cli/src/attach.ts +2 -2
  7. package/spec-cli/src/cli.ts +695 -536
  8. package/spec-cli/src/client.ts +31 -30
  9. package/spec-cli/src/contract-filter.ts +1 -1
  10. package/spec-cli/src/doctor.ts +40 -13
  11. package/spec-cli/src/gateway.ts +11 -7
  12. package/spec-cli/src/git.ts +2 -2
  13. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  14. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  15. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  16. package/spec-cli/src/graphStream.ts +288 -0
  17. package/spec-cli/src/guide.ts +123 -96
  18. package/spec-cli/src/harness-select.ts +2 -2
  19. package/spec-cli/src/harness.ts +30 -14
  20. package/spec-cli/src/help.ts +289 -384
  21. package/spec-cli/src/hooks.ts +1 -1
  22. package/spec-cli/src/index.ts +130 -103
  23. package/spec-cli/src/init.ts +9 -9
  24. package/spec-cli/src/issues.ts +89 -33
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +73 -8
  27. package/spec-cli/src/localIssues.ts +42 -60
  28. package/spec-cli/src/materialize.ts +1 -1
  29. package/spec-cli/src/mentions.ts +15 -15
  30. package/spec-cli/src/migrate-table.ts +397 -0
  31. package/spec-cli/src/migrate.ts +386 -0
  32. package/spec-cli/src/ranker.ts +30 -4
  33. package/spec-cli/src/reaper.ts +117 -0
  34. package/spec-cli/src/search.bench.mjs +10 -10
  35. package/spec-cli/src/search.ts +1 -1
  36. package/spec-cli/src/sessions.ts +244 -138
  37. package/spec-cli/src/specs.ts +25 -15
  38. package/spec-cli/src/supervise.ts +2 -2
  39. package/spec-cli/src/tree.ts +5 -5
  40. package/spec-cli/src/uninstall.ts +4 -4
  41. package/spec-cli/templates/hooks/post-checkout +1 -1
  42. package/spec-cli/templates/hooks/post-merge +3 -3
  43. package/spec-cli/templates/hooks/pre-commit +9 -9
  44. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  45. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  51. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  52. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
  56. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  57. package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
  59. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  60. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/spec.md +1 -1
  62. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
  63. package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
  64. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
  65. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
  66. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
  68. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
  69. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
  70. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
  71. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
  72. package/spec-dashboard/dist/index.html +2 -2
  73. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  74. package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
  75. package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
  76. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  77. package/{spec-yatsu → spec-eval}/src/freshness.ts +50 -24
  78. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  79. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  80. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  81. package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
  82. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  83. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  84. package/spec-forge/src/cli.ts +14 -13
  85. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  86. package/spec-cli/src/boardStream.ts +0 -179
  87. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  88. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  89. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  90. /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
  91. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  92. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  93. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  94. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/memory-hygiene/spec.md +0 -0
@@ -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 issues`,
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[] // yatsu content-addressed blob hashes — the typed cross-node finding reference
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(', ')} (yatsu blob hashes)` : '',
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 issues` drain — is free of them by construction (they reach the EVAL side
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 issues open [--store <store>]` run this SAME routine. Default local commits
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(', ')} (yatsu blob hashes)` : '',
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
- // `spex issues …`the ONE issues surface. Bare (with filters) it is THE read over every store: the
258
- // drain view a supervisor/human works from, `[--node id] [--store local|<host>] [--all] [--json]`. A write
259
- // first-positional (open|reply|on|off|status|nudge — localIssues.ts) routes to the store's
260
- // write verbs (open and reply are themselves store-routed: `open --store <host>` / a `<host>#<n>` id go
261
- // through the driver); `close` is the store-routed lifecycle verb (the SAME closeIssue the dashboard's
262
- // Close button calls); `promote` is the one cross-store verb. The list imposes NO salience ranking —
263
- // replies are a signal the drain WEIGHS by judgment, never an automatic priority
264
- // order. The forge slice is a LIVE pull; an unreachable forge degrades loudly to local-only (one stderr
265
- // note) local reading never hostages on a network.
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 issues close <issue-id> (a local id, or a forge id like github#12)'); return 2 }
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 issues close: ${e instanceof Error ? e.message : e}`)
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 issues promote <local-issue-id>'); return 2 }
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 issues promote: ${e instanceof Error ? e.message : e}`)
357
+ console.error(`spex issue promote: ${e instanceof Error ? e.message : e}`)
294
358
  return 1
295
359
  }
296
360
  }
297
- if (args[0] && !args[0].startsWith('--')) {
298
- console.error(`spex issues: unknown subcommand '${args[0]}'`)
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
- let forge: ForgeSlice | null = null
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 — `spex issues on` to re-enable writes/nudges)')
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
  }
@@ -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 .config tier `spex init` seeds (default 'default'; seed-time only, no launcher gate; read by init.ts; see [[init-preset]])
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 with `spex issues on|off`. (Pre-rename `proposals.enabled` still reads localIssues.ts issuesEnabled.)
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 .config/core/*/). SPEXCODE_HOME overrides the root for test isolation.
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 .config (hooks-manifest, content-hash, plugin-folders).
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-yatsu
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) {
@@ -15,7 +15,7 @@ export type LintConfig = {
15
15
  maxChildren: number // breadth budget: warn at >= this many direct children
16
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 a yatsu scenario's `tags:` must draw from; extend it to mint a new tag
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'],
@@ -55,7 +55,7 @@ export function normalizeConfig(cfg: LintConfig): LintConfig {
55
55
  }
56
56
 
57
57
  // the source-file matcher, built from the configurable `sourceExtensions` knob. Coverage uses it to decide
58
- // which tracked files must be governed; yatsu's `yatsu-uncovered` reuses THE SAME knob so ONE setting
58
+ // which tracked files must be governed; eval lint's `eval-coverage` reuses THE SAME knob so ONE setting
59
59
  // defines "source" for both coverage axes — a non-web project (Rust/Go/Python .rs/.go/.py) sets it once and
60
60
  // both the coverage warning and the loss-signal blind-spot check follow, with no second web-only allowlist.
61
61
  export const sourceExtRe = (extensions: string[]) => new RegExp(`\\.(${extensions.join('|')})$`)
@@ -140,7 +140,7 @@ export async function specLint(): Promise<Finding[]> {
140
140
  out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
141
141
  owners.set(f, [...(owners.get(f) ?? []), s.id])
142
142
  }
143
- // one-govern: a node is source of truth for at most ONE file, so drift/yatsu/ack have a single
143
+ // one-govern: a node is source of truth for at most ONE file, so drift/eval/ack have a single
144
144
  // unambiguous subject (see [[governed-related]]). >1 is a defect — pick the true subject, demote the
145
145
  // rest to related. ERROR (the node-side twin of too-many-owners' file-side bound). 0 is fine.
146
146
  if (s.code.length > 1)
@@ -148,7 +148,7 @@ export async function specLint(): Promise<Finding[]> {
148
148
  }
149
149
  // a file is COVERED if any node GOVERNS (code:) or merely REFERENCES (related:) it; integrity covers both.
150
150
  // `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/yatsu.
151
+ // related, not govern (see [[governed-related]]). It carries coverage but never drift, never eval freshness.
152
152
  const claimed = new Set<string>(owners.keys())
153
153
  for (const s of specs) for (const f of s.related) {
154
154
  if (!existsSync(join(root, f)))
@@ -156,6 +156,71 @@ export async function specLint(): Promise<Finding[]> {
156
156
  claimed.add(f)
157
157
  }
158
158
 
159
+ // id-format: a node id (its leaf dir basename) passes an EXACT per-character whitelist — an ascii char
160
+ // must be [a-z0-9-]; a non-ascii char must be a unicode letter/number (judged on NFC, the mint's
161
+ // canonical form) — and is UNIQUE tree-wide (ERROR). This is THE id vocabulary, defined once (the
162
+ // spec-lint node's rule table) and referenced by [[mentions]] / [[id-url-safe]]: CJK and every other
163
+ // letter script is first-class, exactly what the script-agnostic resolve machinery already accepts.
164
+ // Everything else is forbidden by construction, no heuristics — space, '/', uppercase Latin (lowercase
165
+ // is the Latin norm), control chars, and '_' (reserved as the mint's parent-qualification join; a '_'
166
+ // inside a basename would make that join ambiguous). One optional leading dot is allowed — the
167
+ // reflexive plugin root `.plugins` is dot-prefixed by design. Uniqueness is what keeps the leaf THE id:
168
+ // on a collision the mint must parent-qualify with `_`, so every surface suddenly speaks a longer id
169
+ // than the dir name — legal to the machinery, illegible to people.
170
+ const ID_RE = /^\.?(?:[a-z0-9-]|(?![\x00-\x7F])[\p{L}\p{N}])+$/u
171
+ const leafOf = (p: string) => { const segs = p.split('/'); return segs[segs.length - 2] }
172
+ const byLeaf = new Map<string, string[]>()
173
+ for (const s of specs) {
174
+ const leaf = leafOf(s.path)
175
+ byLeaf.set(leaf, [...(byLeaf.get(leaf) ?? []), s.path])
176
+ if (!ID_RE.test(leaf.normalize('NFC')))
177
+ 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` })
178
+ }
179
+ for (const [leaf, paths] of byLeaf) {
180
+ if (paths.length > 1)
181
+ 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` })
182
+ }
183
+
184
+ // confusable-id: two leaf ids one edit apart read as the same word (WARN — a typo in either reaches a
185
+ // real, wrong node). Deliberately conservative: distance exactly 1, so hierarchy naming like
186
+ // graph/graph-delivery (a whole suffix apart) and verb pairs like evidence-put/evidence-get (distance 2)
187
+ // never warn — better to miss a borderline pair than to nag legitimate siblings. Distance is measured
188
+ // in CODE POINTS, script-agnostic: a CJK pair one character apart (节点/结点 — a classic homophone
189
+ // IME slip) warns like an ascii pair, an astral char counts as one edit (not two surrogate units), and
190
+ // a pure-CJK id can never sit one edit from a pure-ascii one.
191
+ const lev1 = (a: string, b: string): boolean => {
192
+ const A = [...a], B = [...b]
193
+ if (Math.abs(A.length - B.length) > 1 || a === b) return false
194
+ let i = 0
195
+ while (i < A.length && i < B.length && A[i] === B[i]) i++
196
+ const rest = (x: string[], k: number) => x.slice(k).join('')
197
+ return rest(A, i + 1) === rest(B, i + 1) || rest(A, i + 1) === rest(B, i) || rest(A, i) === rest(B, i + 1)
198
+ }
199
+ const leaves = [...byLeaf.keys()]
200
+ for (let i = 0; i < leaves.length; i++) for (let j = i + 1; j < leaves.length; j++) {
201
+ if (lev1(leaves[i], leaves[j]))
202
+ 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` })
203
+ }
204
+
205
+ // mention: a `[[id]]` in a body must name a real node (ERROR — a dangling mention is a broken edge in
206
+ // the very graph the tree exists to keep honest). Checked against the SAME minted ids every other
207
+ // surface resolves ([[id-url-safe]]). Prose only: a fenced block or inline `code span` is sample text
208
+ // (`[[node]]`, `[[<id>]]` placeholders live there), not a reference.
209
+ const idSet = new Set(specs.map((s) => s.id))
210
+ const MENTION_RE = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/gu
211
+ for (const s of specs) {
212
+ let inFence = false
213
+ for (const rawLine of s.body.split('\n')) {
214
+ if (/^\s*```/.test(rawLine)) { inFence = !inFence; continue }
215
+ if (inFence) continue
216
+ const line = rawLine.replace(/`[^`]*`/g, '')
217
+ for (const m of line.matchAll(MENTION_RE)) {
218
+ if (!idSet.has(m[1]))
219
+ 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)` })
220
+ }
221
+ }
222
+ }
223
+
159
224
  // living: a spec body describes the node's CURRENT intent — it is not a changelog. Version history
160
225
  // (every content commit, its reason/session/line-diff) is read from git and shown in the dashboard's
161
226
  // recent/history tabs, so a `## vN`-style heading in the body is duplicated, drift-prone state.
@@ -224,7 +289,7 @@ export async function specLint(): Promise<Finding[]> {
224
289
 
225
290
  // related drift: the SOFT tier ([[governed-related]]). A referenced file moved ahead of the node's
226
291
  // 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 yatsu. It is COMMON (shared substrate and
292
+ // never reaching the commit gate (driftGate reads govern) or eval freshness. It is COMMON (shared substrate and
228
293
  // faces change often without re-versioning every referrer), so per-file it is a wall; like
229
294
  // too-many-owners it collapses to ONE summary line, with the per-file detail riding the board
230
295
  // (relatedDriftFiles). It stays a soft edge, never a per-file interruption.
@@ -233,7 +298,7 @@ export async function specLint(): Promise<Finding[]> {
233
298
  const byNode = new Map<string, number>()
234
299
  for (const d of rd) byNode.set(d.id, (byNode.get(d.id) ?? 0) + 1)
235
300
  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 yatsu). Most: ${worst}` })
301
+ 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
302
  }
238
303
 
239
304
  return out
@@ -242,13 +307,13 @@ export async function specLint(): Promise<Finding[]> {
242
307
  export const DRIFT_GUIDANCE = `DRIFT — a governed file has moved ahead of its spec. A CHECKPOINT, not a chore: find WHERE the truth
243
308
  broke along raw intent → expanded spec → code: link → code structure → implementation, fix THAT layer.
244
309
 
245
- Inspect: spex lint which files, against which spec
310
+ Inspect: spex spec lint which files, against which spec
246
311
  the node's spec.md its raw source + expanded spec
247
312
  git diff $(git log -1 --format=%H -- <spec.md>)..HEAD -- <file> the code delta since the spec
248
313
 
249
314
  Diagnose, then apply the one honest remedy:
250
315
  • 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)
316
+ • only mechanics changed → spex spec ack <node> "checked — spec still valid" (give a real reason)
252
317
  • implementation is WRONG → the spec is right; fix the CODE back toward it, then ack
253
318
  • wrong code: link → the node shouldn't own this file (or owns it too broadly); fix frontmatter
254
319
  • expanded spec ≠ raw → the spec drifted from human intent; fix the expanded spec to serve the raw