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.
Files changed (101) 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/anchors.ts +300 -0
  7. package/spec-cli/src/attach.ts +2 -2
  8. package/spec-cli/src/cli.ts +691 -536
  9. package/spec-cli/src/client.ts +31 -30
  10. package/spec-cli/src/contract-filter.ts +1 -1
  11. package/spec-cli/src/doctor.ts +40 -13
  12. package/spec-cli/src/gateway.ts +11 -7
  13. package/spec-cli/src/git.ts +23 -15
  14. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  15. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  16. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  17. package/spec-cli/src/graphStream.ts +288 -0
  18. package/spec-cli/src/guide.ts +142 -98
  19. package/spec-cli/src/harness-select.ts +2 -2
  20. package/spec-cli/src/harness.ts +30 -14
  21. package/spec-cli/src/help.ts +300 -385
  22. package/spec-cli/src/hooks.ts +1 -1
  23. package/spec-cli/src/index.ts +144 -103
  24. package/spec-cli/src/init.ts +14 -13
  25. package/spec-cli/src/issues.ts +89 -33
  26. package/spec-cli/src/layout.ts +5 -5
  27. package/spec-cli/src/lint.ts +128 -30
  28. package/spec-cli/src/localIssues.ts +61 -60
  29. package/spec-cli/src/materialize.ts +1 -1
  30. package/spec-cli/src/mentions.ts +15 -15
  31. package/spec-cli/src/migrate-table.ts +398 -0
  32. package/spec-cli/src/migrate.ts +386 -0
  33. package/spec-cli/src/ranker.ts +30 -4
  34. package/spec-cli/src/reaper.ts +117 -0
  35. package/spec-cli/src/search.bench.mjs +12 -12
  36. package/spec-cli/src/search.ts +1 -1
  37. package/spec-cli/src/sessions.ts +244 -138
  38. package/spec-cli/src/specs.ts +40 -25
  39. package/spec-cli/src/supervise.ts +2 -2
  40. package/spec-cli/src/tree.ts +5 -5
  41. package/spec-cli/src/uninstall.ts +4 -4
  42. package/spec-cli/templates/hooks/post-checkout +1 -1
  43. package/spec-cli/templates/hooks/post-merge +3 -3
  44. package/spec-cli/templates/hooks/pre-commit +9 -9
  45. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  51. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  52. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
  56. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  59. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  60. package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
  61. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  62. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  63. package/spec-cli/templates/spec/project/spec.md +1 -1
  64. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
  65. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
  66. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
  67. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
  68. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
  69. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
  70. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
  71. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
  74. package/spec-dashboard/dist/index.html +2 -2
  75. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  76. package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
  77. package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
  78. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  79. package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
  80. package/spec-eval/src/humanok.ts +43 -0
  81. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  82. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  83. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  84. package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
  85. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  86. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  87. package/spec-forge/src/cli.ts +14 -13
  88. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  89. package/spec-cli/src/boardStream.ts +0 -179
  90. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
  91. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  92. package/spec-cli/templates/spec/project/.config/spec.md +0 -15
  93. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  94. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  98. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  99. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  100. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  101. /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
@@ -1,5 +1,5 @@
1
1
  import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
2
- import type { SessionEvals } from '../../spec-yatsu/src/proof.js'
2
+ import type { SessionEvals } from '../../spec-eval/src/sessioneval.js'
3
3
 
4
4
  export class BackendError extends Error {
5
5
  constructor(message: string, readonly status?: number) {
@@ -25,7 +25,7 @@ const guarded = (verb: string) => assertProjectMatch(`spex ${verb}`)
25
25
  const post = (body: unknown): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
26
26
  const seg = (id: string) => encodeURIComponent(id)
27
27
 
28
- // GET /api/sessions — the board, used by `spex ls`, and by `spex watch`/`wait` as their poll source.
28
+ // GET /api/sessions — the board, used by `spex session ls`, and by `spex session watch`/`wait` as their poll source.
29
29
  export async function clientListSessions(): Promise<Session[]> {
30
30
  const r = await apiFetch('/api/sessions')
31
31
  if (!r.ok) throw new BackendError(`backend error ${r.status} listing sessions`, r.status)
@@ -46,13 +46,13 @@ export async function clientCapture(id: string): Promise<CaptureResult> {
46
46
  return { ok: false, status: r.status, reason: (await r.text().catch(() => '')) || `status ${r.status}` }
47
47
  }
48
48
 
49
- // POST /api/sessions/:id/keys — prompt dispatch (the backend routes it through the rendezvous socket,
50
- // socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
49
+ // POST /api/sessions/:id/input {kind:"text"} — prompt dispatch (the backend routes it through the rendezvous
50
+ // socket, socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
51
51
  export async function clientSend(id: string, text: string, from?: string): Promise<DispatchResult> {
52
52
  await guarded('session send')
53
53
  // `from` = the sending agent's own session id; the backend logs the comms edge ([[comms-edge]]) only when
54
54
  // it's present (an agent send), so a human-shell send stays unrecorded.
55
- const r = await apiFetch(`/api/sessions/${seg(id)}/keys`, post({ text, ...(from ? { from } : {}) }))
55
+ const r = await apiFetch(`/api/sessions/${seg(id)}/input`, post({ kind: 'text', text, ...(from ? { from } : {}) }))
56
56
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
57
57
  }
58
58
 
@@ -64,18 +64,18 @@ export async function clientReview(id: string): Promise<ReviewPayload | null> {
64
64
  return await r.json() as ReviewPayload
65
65
  }
66
66
 
67
- // GET /api/sessions/:id/proof — the rendered proof EXPORT artifact ([[review-proof]]): the self-contained
68
- // HTML the backend builds (default), or the model JSON (`json:true` ?format=json). The engine runs on the
69
- // backend, so the CLI is a thin fetcher that writes/opens these bytes — works against a remote backend
70
- // unchanged. 404 no such session.
71
- export type ProofResult = { ok: true; body: string } | { ok: false; status: number }
72
- export async function clientProof(id: string, json = false): Promise<ProofResult> {
73
- const r = await apiFetch(`/api/sessions/${seg(id)}/proof${json ? '?format=json' : ''}`)
67
+ // GET /api/sessions/:id/evals?format=html — the rendered EXPORT artifact ([[session-eval]]): the
68
+ // self-contained HTML the backend builds. The engine runs on the backend, so the CLI is a thin fetcher
69
+ // that writes/opens these bytes — works against a remote backend unchanged. 404 → no such session.
70
+ export type ExportResult = { ok: true; body: string } | { ok: false; status: number }
71
+ export async function clientEvalExport(id: string): Promise<ExportResult> {
72
+ const r = await apiFetch(`/api/sessions/${seg(id)}/evals?format=html`)
74
73
  if (r.ok) return { ok: true, body: await r.text() }
75
74
  return { ok: false, status: r.status }
76
75
  }
77
76
 
78
- // GET /api/sessions/:id/evals — the session EVAL model ([[review-proof]]'s interactive face): the changed
77
+ // GET /api/sessions/:id/evals — the session EVAL model ([[session-eval]]'s interactive face, the same
78
+ // route's default JSON representation): the changed
79
79
  // nodes' worktree-rooted reading rows (each carrying `inSession`), no diff enrichment, no inlined evidence
80
80
  // bytes — what `spex eval` renders, the dashboard Eval tab's source. 404 → no such session.
81
81
  export type EvalsResult = { ok: true; model: SessionEvals } | { ok: false; status: number }
@@ -95,17 +95,17 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
95
95
  // POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
96
96
  // working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
97
97
  // `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
98
- export async function clientReopen(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
99
- await guarded('session reopen')
98
+ export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
99
+ await guarded('session resume')
100
100
  const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
101
101
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
102
102
  }
103
103
 
104
- // POST /api/sessions/:id/exit — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,
104
+ // POST /api/sessions/:id/stop — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,
105
105
  // resumable). Distinct from close. {ok:false} = no such session.
106
- export async function clientExit(id: string): Promise<boolean> {
107
- await guarded('session exit')
108
- const r = await apiFetch(`/api/sessions/${seg(id)}/exit`, post({}))
106
+ export async function clientStop(id: string): Promise<boolean> {
107
+ await guarded('session stop')
108
+ const r = await apiFetch(`/api/sessions/${seg(id)}/stop`, post({}))
109
109
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
110
110
  }
111
111
 
@@ -124,19 +124,20 @@ export async function clientRename(id: string, name: string): Promise<boolean> {
124
124
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
125
125
  }
126
126
 
127
- // POST /api/sessions/:id/rawkey — the raw nav-key channel (tmux send-keys, NEVER the prompt socket): an
128
- // ordered token batch drives an interactive TUI menu ([[nav-mode-key-ordering]]). {ok:false} = unknown
129
- // session, no live pane, or no valid token delivered.
130
- export async function clientRawkey(id: string, keys: string[]): Promise<boolean> {
131
- await guarded('session rawkey')
132
- const r = await apiFetch(`/api/sessions/${seg(id)}/rawkey`, post({ keys }))
127
+ // POST /api/sessions/:id/input {kind:"keys"} — the LAST-RESORT raw nav-key face of send (tmux send-keys,
128
+ // NEVER the prompt socket): an ordered token batch drives an interactive TUI menu
129
+ // ([[nav-mode-key-ordering]]). {ok:false} = unknown session, no live pane, or no valid token delivered.
130
+ export async function clientSendRawKeys(id: string, keys: string[]): Promise<boolean> {
131
+ await guarded('session send')
132
+ const r = await apiFetch(`/api/sessions/${seg(id)}/input`, post({ kind: 'keys', keys }))
133
133
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
134
134
  }
135
135
 
136
- // GET /api/sessions/:id/prompt — the session's originating prompt (404 if none recorded).
137
- export type PromptResult = { ok: true; prompt: string } | { ok: false; status: number }
138
- export async function clientPrompt(id: string): Promise<PromptResult> {
139
- const r = await apiFetch(`/api/sessions/${seg(id)}/prompt`)
140
- if (r.ok) return { ok: true, prompt: await r.text() }
136
+ // GET /api/sessions/:id — the session RECORD detail (`spex session show`): the board row plus the full
137
+ // originating prompt. 404 no such session.
138
+ export type ShowResult = { ok: true; session: Session & { prompt: string | null } } | { ok: false; status: number }
139
+ export async function clientShow(id: string): Promise<ShowResult> {
140
+ const r = await apiFetch(`/api/sessions/${seg(id)}`)
141
+ if (r.ok) return { ok: true, session: await r.json() as Session & { prompt: string | null } }
141
142
  return { ok: false, status: r.status }
142
143
  }
@@ -138,7 +138,7 @@ export function settleIndexStat(proj: string, files: string[]): void {
138
138
 
139
139
  // the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
140
140
  // config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
141
- // (yatsu blobs), so only OUR two files go, never the dir.
141
+ // (evidence blobs), so only OUR two files go, never the dir.
142
142
  export function removeContractFilter(proj: string): void {
143
143
  let common: string
144
144
  try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
13
13
  import { execFileSync } from 'node:child_process'
14
14
  import { homedir } from 'node:os'
15
15
  import { loadSystemConfig, loadSkillConfig } from './specs.js'
16
- import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord } from './layout.js'
16
+ import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout } from './layout.js'
17
17
 
18
18
  // this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
19
19
  // materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
@@ -219,6 +219,21 @@ async function doctor(): Promise<number> {
219
219
  line('spex-adopted', adopted ? 'yes (.spec/ + spexcode.json)' : root ? 'no — run `spex init`' : 'not a git repo')
220
220
  line('root', base)
221
221
  line('mode', managed ? 'managed worktree (backend-launched session)' : 'standalone repo (bring-your-own-agent)')
222
+ // the issues-workflow switch ([[local-issues]]): its only home is the `issues.enabled` settings key
223
+ // (v0.3.0 — the on|off|status CLI verbs are gone), so doctor is where its state is READ. A legacy
224
+ // pre-rename `proposals.enabled` key is no longer consulted at runtime (no fallback) — flag it here so
225
+ // an old settings file gets repaired instead of silently drifting from what its author believes.
226
+ if (adopted) {
227
+ const { issuesEnabled } = await import('./localIssues.js')
228
+ // probe the SAME files the switch actually reads — the trunk checkout's settings pair, not this
229
+ // worktree's (readConfig resolves to mainCheckout, so a legacy key only matters there).
230
+ const cfgHome = (() => { try { return mainCheckout() } catch { return base } })()
231
+ const legacy = ['spexcode.json', 'spexcode.local.json'].filter((f) => {
232
+ try { return 'proposals' in JSON.parse(readFileSync(join(cfgHome, f), 'utf8')) } catch { return false }
233
+ })
234
+ line('issues workflow', `${issuesEnabled() ? 'ON' : 'OFF'} (spexcode.json issues.enabled)`)
235
+ if (legacy.length) line(' LEGACY key', `\`proposals\` found in ${legacy.map((f) => join(cfgHome, f)).join(', ')} — no longer read; rename it to "issues": { "enabled": … }`)
236
+ }
222
237
 
223
238
  // --- preconditions: nothing downstream fires without these ---
224
239
  L.push('\nPreconditions (without these nothing downstream fires)')
@@ -247,7 +262,7 @@ async function doctor(): Promise<number> {
247
262
  const present = h.contractFiles(base).every((f) => /<!--\s*spexcode:start\s*-->/.test(read(f)))
248
263
  line(`in ${h.id}`, present ? `block present (${h.contractFiles(base).map((f) => f.replace(base + '/', '')).join(', ')})` : 'NOT landed — run `spex doctor contract` / materialize')
249
264
  }
250
- line('view', 'spex doctor contract')
265
+ line('view', 'spex doctor --contract')
251
266
 
252
267
  // --- hooks: the shim → dispatch, the manifest, and EVERY handler readable in the worktree ---
253
268
  L.push('\nLayer 3 — hooks (shim → dispatch · manifest · handler-existence)')
@@ -293,7 +308,7 @@ async function doctor(): Promise<number> {
293
308
  line('layer 2', body.length === 0 ? 'ABSENT (no contract)' : 'see per-harness above')
294
309
  line('layer 3', manifestText ? 'see handler-existence above' : 'ABSENT (no manifest — agent ungoverned)')
295
310
  line('layer 4', up ? 'present' : managed ? 'EXPECTED but backend down' : 'absent (normal for bring-your-own-agent)')
296
- line('layer 5', dd.conflict ? 'CONFLICT (double-delivery — see Layer 5; `spex doctor conflicts`)' : 'clean (single channel)')
311
+ line('layer 5', dd.conflict ? 'CONFLICT (double-delivery — see Layer 5; `spex doctor --conflicts`)' : 'clean (single channel)')
297
312
 
298
313
  // --- footprint: every artifact Spex wrote here, + any slot held by something not ours ---
299
314
  L.push('\nFootprint (what Spex wrote into this environment)')
@@ -323,12 +338,12 @@ function contract(): number {
323
338
  }
324
339
 
325
340
  // the focused double-delivery check: JUST Layer 5, exit non-zero when a conflict is live so it gates a script
326
- // / yatsu. Anchors at the repo root like doctor (the shims + contract + skills live there).
341
+ // / eval. Anchors at the repo root like doctor (the shims + contract + skills live there).
327
342
  async function conflicts(): Promise<number> {
328
343
  const cwd = process.cwd()
329
344
  const base = repoRoot(cwd) ?? cwd
330
345
  const { lines, conflict } = await doubleDeliveryReport(base)
331
- console.log(['spex doctor conflicts — does SpexCode reach this agent through more than one discovery channel?\n', ...lines].join('\n'))
346
+ console.log(['spex doctor --conflicts — does SpexCode reach this agent through more than one discovery channel?\n', ...lines].join('\n'))
332
347
  return conflict ? 1 : 0
333
348
  }
334
349
 
@@ -338,26 +353,38 @@ async function conflicts(): Promise<number> {
338
353
  function noteStaged(verb: string): number {
339
354
  console.error(`spex doctor ${verb} is not available yet — it is staged behind the hook-degradation prerequisite
340
355
  (the live hooks must detect a missing managed session and degrade before they can be safely wired into your
341
- own agent's config). Meanwhile: \`spex doctor\` reports your coverage, and \`spex doctor contract\` prints
356
+ own agent's config). Meanwhile: \`spex doctor\` reports your coverage, and \`spex doctor --contract\` prints
342
357
  the workflow text you can hand any agent.`)
343
358
  return 2
344
359
  }
345
360
 
346
361
  function usage(): number {
347
362
  console.error(`spex doctor — diagnose how the SpexCode workflow reaches your agent
348
- (bare) per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
349
- contract print the surface:system contract text (hand it to any agent)
350
- conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
351
- install [staged] wire the materialized contract + hooks into your agent (--agent claude, --minimal)
352
- uninstall [staged] reverse exactly what install wrote`)
363
+ (bare) per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
364
+ --contract print the surface:system contract text (hand it to any agent)
365
+ --conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
366
+ --migrate one-shot 0.2.x 0.3.0 vocabulary migration for an adopter repo (renames + hook-asset
367
+ upgrade + body rewrites; refuses on a dirty tree / undrained sessions / an already-migrated
368
+ tree; stages everything, commits nothing; removed in 0.4.0)
369
+ install [staged] wire the materialized contract + hooks into your agent (--agent claude, --minimal)
370
+ uninstall [staged] reverse exactly what install wrote`)
353
371
  return 0
354
372
  }
355
373
 
356
374
  export async function runDoctor(args: string[]): Promise<number> {
375
+ // contract/conflicts are FLAGS, not subcommands ([[cli-surface]] §4: another representation of the same
376
+ // diagnosis read, not a distinct action). The old positional spellings signpost — report, never run.
377
+ // --migrate is the one MUTATING flag: the term-limited 0.2.x→0.3.0 adopter migrator ([[migrate]],
378
+ // ships with 0.3.0, deleted in 0.4.0) — dispatched first so its refusal/summary is never diluted
379
+ // by the diagnosis report.
380
+ if (args.includes('--migrate')) return await (await import('./migrate.js')).runMigrate()
381
+ if (args.includes('--contract')) return contract()
382
+ if (args.includes('--conflicts')) return await conflicts()
357
383
  switch (args[0]) {
358
384
  case undefined: return await doctor()
359
- case 'contract': return contract()
360
- case 'conflicts': return await conflicts()
385
+ case 'contract': case 'conflicts':
386
+ console.error(`spex: \`spex doctor ${args[0]}\` was removed in v0.3.0 — use: spex doctor --${args[0]}`)
387
+ return 2
361
388
  case 'install': return noteStaged('install')
362
389
  case 'uninstall': return noteStaged('uninstall')
363
390
  case 'help': case '--help': case '-h': return usage()
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url'
15
15
  import { homedir } from 'node:os'
16
16
  import { loginPage } from './login-page.js'
17
17
  import { listenOrExit } from './listen.js'
18
+ import { installConnectionReaper } from './reaper.js'
18
19
 
19
20
  // @@@ resolvePublicConfig - the cert/gate is a RESOLVED value, never hardcoded. Reads the same precedence
20
21
  // chain the spec promises: flag > env > spexcode.json > self-signed default. Returns null when public mode
@@ -150,14 +151,17 @@ export function startGateway(opts: GatewayOpts): void {
150
151
  }
151
152
 
152
153
  // server-side connection reaping ([[spec-cli]] / [[public-mode]]) - the internet-facing gateway is the
153
- // public server in public mode, so it carries the SAME reaping as the child: an abandoned/slow connection
154
- // dies here instead of piling up. Idle keep-alive / slow-header / never-completing request only; the gated
155
- // WS upgrade (handled below) is an active connection, not reaped by these. Set AT CONSTRUCTION so the
156
- // connection-checking sweep is armed with our cadence (a post-hoc set leaves it under-effective).
154
+ // public server in public mode, so it carries the SAME reaping as the child. The `reap` HTTP timeouts are
155
+ // kept (harmless) but are NOT the mechanism MEASURED (eval server-reaps-abandoned-connections), Node's
156
+ // headersTimeout/requestTimeout don't reap an INCOMPLETE request; the real reaper is the socket-level
157
+ // `installConnectionReaper` (reaper.ts), applied below at every http server the serving path creates.
158
+ // Idle keep-alive / slow-loris / never-completing request only; the gated WS upgrade (handled below) is an
159
+ // active stream and exempt for its lifetime.
157
160
  const reap = { keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000 }
158
161
  const server = secure
159
162
  ? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key, ...reap }, handler)
160
163
  : http.createServer(reap, handler)
164
+ installConnectionReaper(server)
161
165
 
162
166
  // @@@ WS gate - the terminal socket rides an HTTP upgrade. Gate it by the SAME cookie (the browser sends
163
167
  // it on the same-origin handshake), then raw-pipe to the loopback supervisor, replaying the buffered
@@ -173,7 +177,7 @@ export function startGateway(opts: GatewayOpts): void {
173
177
  socket.on('error', bail); up.on('error', bail)
174
178
  })
175
179
 
176
- // `spex dashboard` passes an explicit host (loopback by default, --host to widen); `--public` passes
180
+ // `spex serve ui` passes an explicit host (loopback by default, --host to widen); `--public` passes
177
181
  // none → bind ALL interfaces (the original behaviour, IPv4+IPv6), so adding the local path never
178
182
  // narrows the public gateway's reach. The gate note keys on LOOPBACK, not on host-being-explicit:
179
183
  // an ungated loopback bind is normal, an ungated wide bind is announced — never silent.
@@ -186,7 +190,7 @@ export function startGateway(opts: GatewayOpts): void {
186
190
  if (!secure && !isLoopback && !opts.host) console.log('[gateway] (TLS off — --http)')
187
191
  }
188
192
  // a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
189
- // (see [[spec-cli]] / listen.ts), so `spex serve` and `spex dashboard` fail a port clash identically.
193
+ // (see [[spec-cli]] / listen.ts), so `spex serve` and `spex serve ui` fail a port clash identically.
190
194
  listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, onListen })
191
195
  }
192
196
 
@@ -271,7 +275,7 @@ export function ensureDashboardBuilt(repoRoot: string, distDir: string): void {
271
275
  }
272
276
  }
273
277
 
274
- // @@@ serveDashboardLocal - the engine behind `spex dashboard`: the SAME gateway as public mode, bound to
278
+ // @@@ serveDashboardLocal - the engine behind `spex serve ui`: the SAME gateway as public mode, bound to
275
279
  // loopback by default with no TLS and no password — `--host` widens the bind to a chosen interface
276
280
  // (LAN/tailnet viewing) while staying plain HTTP; the internet face remains `spex serve --public`.
277
281
  // It serves the bundled dist and proxies /api + the terminal socket to a separately-run `spex serve`.
@@ -6,7 +6,7 @@ import { join, isAbsolute, resolve } from 'node:path'
6
6
  const US = '\x1f', RS = '\x1e'
7
7
 
8
8
  // @@@ bounded git children - a git child that never exits (wedged fs, a hijacked PATH git, a dead network
9
- // mount) must not pin its awaiter forever: [[board-cache]]'s settle guarantee starts at this seam. Every
9
+ // mount) must not pin its awaiter forever: [[graph-cache]]'s settle guarantee starts at this seam. Every
10
10
  // shared helper passes a generous timeout (an order of magnitude above the slowest legitimate full-history
11
11
  // walk) with SIGKILL — same pattern sessions.ts's tmux/ps probes already use — so a hung child dies and the
12
12
  // call fails like any other git failure instead of hanging its caller's promise. The kill is warned loudly:
@@ -154,7 +154,7 @@ function parseStatPath(token: string): { from: string; to: string } {
154
154
  }
155
155
 
156
156
  // Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
157
- // once — the backend checkout (board, loadSpecs) plus every session worktree ([[review-proof]]'s eval
157
+ // once — the backend checkout (board, loadSpecs) plus every session worktree ([[session-eval]]'s eval
158
158
  // tab roots its readings at the session's branch). A single-slot cache thrashes between those roots:
159
159
  // each eval-tab request evicts the board's entry and vice versa, so every request re-runs a full-history
160
160
  // `git log` and re-parses it on the event loop — which is what starves every other request (the board,
@@ -366,28 +366,36 @@ export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boo
366
366
  return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
367
367
  }
368
368
 
369
- // pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
370
- // in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
371
- //
372
- // `sinceHash` is the node's OWN latest version commit, so the node(s) it's a version of
373
- // (specNodes[sinceHash]) name the node being measured; an ack counts only if its `Spec-OK:` set names
374
- // one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that is itself an ancestor of the
375
- // version can't speak for it (a re-version invalidates older acks); a valid ack quiets exactly the
376
- // commits reachable from it. An off-history `sinceHash` → 0: no basis on HEAD to measure from.
377
- export function driftFor(idx: DriftIndex, sinceHash: string, path: string): number {
378
- if (!sinceHash) return 0
369
+ // the valid Spec-OK coverage for a node's version commit: `sinceHash` is the node's OWN latest version,
370
+ // so the node(s) it's a version of (specNodes[sinceHash]) name the node being measured; an ack counts
371
+ // only if its `Spec-OK:` set names one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that
372
+ // is itself an ancestor of the version can't speak for it (a re-version invalidates older acks); a valid
373
+ // ack quiets exactly the commits reachable from it. Shared by driftFor (the count) and the anchor
374
+ // engine's windowCommits (the commit set) so both read ONE ack rule.
375
+ export function ackCoverFor(idx: DriftIndex, sinceHash: string): Uint8Array[] {
379
376
  const base = ancestorsOf(idx, sinceHash)
380
- if (!base) return 0
377
+ if (!base) return []
381
378
  const targets = idx.specNodes.get(sinceHash)
382
- const ackCover: Uint8Array[] = []
379
+ const cover: Uint8Array[] = []
383
380
  if (targets) {
384
381
  for (const [h, ackSet] of idx.acks) {
385
382
  if (inAncestors(idx, base, h)) continue
386
383
  if (![...targets].some((t) => ackSet.has(t))) continue
387
384
  const a = ancestorsOf(idx, h)
388
- if (a) ackCover.push(a)
385
+ if (a) cover.push(a)
389
386
  }
390
387
  }
388
+ return cover
389
+ }
390
+
391
+ // pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
392
+ // in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
393
+ // An off-history `sinceHash` → 0: no basis on HEAD to measure from.
394
+ export function driftFor(idx: DriftIndex, sinceHash: string, path: string): number {
395
+ if (!sinceHash) return 0
396
+ const base = ancestorsOf(idx, sinceHash)
397
+ if (!base) return 0
398
+ const ackCover = ackCoverFor(idx, sinceHash)
391
399
  let n = 0
392
400
  for (const h of idx.fileCommits.get(path) ?? []) {
393
401
  if (inAncestors(idx, base, h)) continue // reachable from the version → not drift
@@ -6,8 +6,8 @@ import { repoRoot, driftIndex, historyIndex } from './git.js'
6
6
  import { residentForgeState } from '../../spec-forge/src/resident.js'
7
7
  import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
8
8
  import { mergedIssues } from './issues.js'
9
- import { evalContext, evalTimeline } from '../../spec-yatsu/src/evaltab.js'
10
- import { yatsuNodesAsync } from '../../spec-yatsu/src/yatsu.js'
9
+ import { evalContext, evalTimeline } from '../../spec-eval/src/evaltab.js'
10
+ import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
11
11
 
12
12
  // a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
13
13
  function resolveParent(path: string, byDir: Record<string, string>): string | null {
@@ -20,7 +20,7 @@ function resolveParent(path: string, byDir: Record<string, string>): string | nu
20
20
  return null
21
21
  }
22
22
 
23
- // the board's eval summary ([[board-lean]]): the LATEST reading per scenario, each kept as the VERBATIM
23
+ // the board's eval summary ([[graph-lean]]): the LATEST reading per scenario, each kept as the VERBATIM
24
24
  // reading object — a filter, never a projection. Consumers hang optional fields off a reading (the
25
25
  // annotator's timelineBlob rides only video readings), so dropping a field here is a SILENT downstream
26
26
  // degradation no error would surface; the field-preservation unit test pins this contract.
@@ -29,7 +29,7 @@ export function latestPerScenario<T extends { scenario: string }>(readings: T[])
29
29
  return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
30
30
  }
31
31
 
32
- // the board's scenario fold ([[board-lean]]): the declared set rides SLIM — {name, tags} is everything an
32
+ // the board's scenario fold ([[graph-lean]]): the declared set rides SLIM — {name, tags} is everything an
33
33
  // overview surface joins state onto (badge, stats, focus rows, search rows); the prose (description/
34
34
  // expected) and per-scenario code stay off the hot poll, carried by `/api/specs/lite` and `/api/specs/:id/evals`.
35
35
  export function slimScenarios(scenarios: { name: string; tags?: string[] }[]): { name: string; tags?: string[] }[] {
@@ -44,7 +44,7 @@ export async function buildBoard() {
44
44
  const root = repoRoot()
45
45
  const [layout, sessions, specs] = await Promise.all([resolveLayout(), listSessions(), loadSpecs()])
46
46
  // the eval fold's freshness axes: WARM hits — loadSpecs already computed this HEAD's drift + history
47
- // indices, so these are the same cached walks, fetched once and reused for every yatsu node (the history
47
+ // indices, so these are the same cached walks, fetched once and reused for every measurable node (the history
48
48
  // index drives the rename-safe scenario axis, mirroring a spec node's own freshness).
49
49
  const idx = await driftIndex(root)
50
50
  const hidx = await historyIndex(root)
@@ -96,7 +96,7 @@ export async function buildBoard() {
96
96
  const nodes = [
97
97
  ...specs.map((n: any) => {
98
98
  const overlays = overlaysByNode[n.id] || []
99
- // `body` and its derivation `parts` are DROPPED from the board payload ([[board-lean]]): together ~56% of
99
+ // `body` and its derivation `parts` are DROPPED from the board payload ([[graph-lean]]): together ~56% of
100
100
  // the bytes, and detail the graph overview never renders. The detail view fetches them per node from
101
101
  // `/api/specs/:id/content` on open, and the search palette fetches the body corpus from `/api/specs/lite`
102
102
  // once on open — both off this hot poll. `undefined` makes JSON.stringify omit the keys.
@@ -109,8 +109,21 @@ export async function buildBoard() {
109
109
  // openIssues, attached only when non-empty). Non-blocking: residentForgeState never waits on `gh` and is
110
110
  // empty absent a forge, so the fold then carries the local slice alone. Sorted open-first, newest first.
111
111
  const isOpen = (i: { status: string }) => i.status === 'open'
112
+ const merged = mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id))
113
+ // ONE board-level freshness stamp over EVERY issue thread (noded or nodeless, both stores):
114
+ // open-count : thread-count : reply-count : latest-activity. Every thread write — open, reply, remark,
115
+ // resolve, retract, close — moves at least one component, so a store write ALWAYS moves board bytes:
116
+ // [[graph-delta]] suppresses no-change broadcasts, and without a moving byte an external write would
117
+ // stay invisible to viewers until the fallback poll ([[remark-substrate]] write-visibility). The per-node
118
+ // fold below stays [[graph-lean]]-slim (no reply payloads); this stamp is the freshness carrier.
119
+ const issuesStamp = [
120
+ merged.filter(isOpen).length,
121
+ merged.length,
122
+ merged.reduce((n, i) => n + i.replies.length, 0),
123
+ merged.flatMap((i) => [i.created, ...i.replies.flatMap((r) => [r.at, r.resolvedAt ?? ''])]).reduce((a, b) => (b > a ? b : a), ''),
124
+ ].join(':')
112
125
  const issuesByNode: Record<string, ReturnType<typeof mergedIssues>> = {}
113
- for (const issue of mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id)))
126
+ for (const issue of merged)
114
127
  for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
115
128
  for (const n of nodes) {
116
129
  const issues = issuesByNode[n.id]
@@ -122,20 +135,20 @@ export async function buildBoard() {
122
135
  if (open.length) n.openIssues = open
123
136
  }
124
137
 
125
- // fold each yatsu node's eval state onto it — as the LEAN summary ([[board-lean]]): `evals` carries only
138
+ // fold each measurable node's eval state onto it — as the LEAN summary ([[graph-lean]]): `evals` carries only
126
139
  // the LATEST reading per scenario (newest-first), which is all any overview surface consumes (the score
127
140
  // badge, stats, search all reduce to latest-per-scenario anyway); the full timeline stays off the board
128
141
  // and is lazy-loaded by the eval tab from `/api/specs/:id/evals`. `scenarios` (the declared set) rides
129
142
  // SLIM — {name, tags} only, the fields every overview surface joins state onto — with its prose
130
143
  // (description/expected) and per-scenario code off the hot poll: they ride the `/api/specs/lite` corpus
131
144
  // (search palette, focus-panel preview) and the `/api/specs/:id/evals` timeline (eval tab).
132
- // evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-yatsu nodes. The
133
- // yatsu walk rides fs/promises ([[board-cache]]) so it yields the event loop instead of stalling /health.
134
- const ynodes = await yatsuNodesAsync(root)
145
+ // evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-measurable nodes. The
146
+ // eval-file walk rides fs/promises ([[graph-cache]]) so it yields the event loop instead of stalling /health.
147
+ const ynodes = await evalNodesAsync(root)
135
148
  const ectx = await evalContext(root, specs, idx, hidx, undefined, ynodes)
136
149
  await Promise.all(nodes.map(async (n) => {
137
150
  const tl = await evalTimeline(n.id, ectx)
138
- if (tl.hasYatsu) { n.evals = latestPerScenario(tl.readings); n.scenarios = slimScenarios(tl.scenarios) }
151
+ if (tl.hasEvalFile) { n.evals = latestPerScenario(tl.readings); n.scenarios = slimScenarios(tl.scenarios) }
139
152
  }))
140
153
 
141
154
  const opsByPath: Record<string, any[]> = {}
@@ -144,6 +157,23 @@ export async function buildBoard() {
144
157
 
145
158
  const dash = readConfig(root).dashboard
146
159
  // project names the tab ([[tab-title]]); projectIcon is the tab favicon ([[tab-icon]]) — both ride the
147
- // /api/board poll so they re-derive from whichever backend the viewer reached. Empty icon → frontend default.
148
- return { nodes, sessions: sess, project: dash?.title || basename(root), projectIcon: dash?.icon || '' }
160
+ // /api/graph poll so they re-derive from whichever backend the viewer reached. Empty icon → frontend default.
161
+ return { nodes, sessions: sess, project: dash?.title || basename(root), projectIcon: dash?.icon || '', issuesStamp }
162
+ }
163
+
164
+ // @@@ spliceSessions — the SESSIONS-ONLY producer ([[graph-cache]]). A session-scoped change (a lifecycle
165
+ // write, a liveness/activity poll flip) reshapes only the board's `sessions` rows — the node/meta units are
166
+ // untouched — so the cache re-derives ONLY the sessions and splices them onto the previous board verbatim,
167
+ // skipping the whole loadSpecs/layout/eval assembly a full buildBoard() pays. Pure aside from listSessions:
168
+ // each row is decorated EXACTLY as buildBoard's sess mapping (`{...s, source: s.path, ops}`), and every
169
+ // path's `ops` is REUSED from the previous board (a path→ops map). A session path absent in `prev` gets []
170
+ // — a brand-new worktree has no pending spec ops yet, and any later ops-CHANGING event (a commit, a
171
+ // worktree `.spec` edit) is refs/worktree-scoped, i.e. a FULL rebuild, never a sessions splice. So the
172
+ // splice is byte-indistinguishable from a full rebuild whenever only session state moved.
173
+ export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>>): Promise<Awaited<ReturnType<typeof buildBoard>>> {
174
+ const sessions = await listSessions()
175
+ const opsByPath: Record<string, any[]> = {}
176
+ for (const s of prev.sessions) opsByPath[s.source] = s.ops
177
+ const sess = sessions.map((s) => ({ ...s, source: s.path, ops: opsByPath[s.path] || [] }))
178
+ return { ...prev, sessions: sess }
149
179
  }