spexcode 0.4.1 → 0.4.3

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 (106) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +110 -18
  7. package/spec-cli/src/cli.ts +88 -37
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +27 -19
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +54 -43
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +105 -55
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +181 -33
  19. package/spec-cli/src/help.ts +64 -27
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +145 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +25 -121
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +14 -0
  39. package/spec-cli/src/sessions.ts +188 -66
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +12 -7
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  48. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +49 -28
  90. package/spec-eval/src/humanok.ts +1 -1
  91. package/spec-eval/src/matrix.ts +693 -0
  92. package/spec-eval/src/sessioneval.ts +726 -66
  93. package/spec-cli/src/migrate-table.ts +0 -408
  94. package/spec-cli/src/migrate.ts +0 -386
  95. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  96. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  97. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  98. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  99. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  100. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  101. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  102. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  103. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  104. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  105. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  106. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
@@ -1,6 +1,6 @@
1
1
  export {} // make this a module so top-level await is allowed
2
2
  // static import is fine here: mentions.ts is dependency-free at module level, and stripRefSigil is needed
3
- // by several verbs (spec owner, session new, graph) — a CLI reference arg tolerates an optional @/[[ ]]
3
+ // by several verbs (spec owner, graph, issue/eval node args) — a CLI reference arg tolerates an optional @/[[ ]]
4
4
  // sigil ([[mentions]]).
5
5
  import { stripRefSigil } from './mentions.js'
6
6
 
@@ -60,6 +60,19 @@ function positionals(from: number): string[] {
60
60
  return out
61
61
  }
62
62
 
63
+ function rejectUnknownFlags(command: string, from: number, allowed: readonly string[]): void {
64
+ const known = new Set(allowed.map((name) => `--${name}`))
65
+ for (let i = from; i < process.argv.length; i++) {
66
+ const token = process.argv[i]
67
+ if (!token.startsWith('--')) continue
68
+ if (!known.has(token)) {
69
+ console.error(`${command}: unknown flag ${token}`)
70
+ process.exit(2)
71
+ }
72
+ if (VALUE_FLAGS.has(token)) i++
73
+ }
74
+ }
75
+
63
76
  // @@@ signposts (one version only — delete in 0.4.0) - every spelling v0.3.0 removed maps to its new home.
64
77
  // A signpost REPORTS and exits 2; it never executes (not an alias): a stale hook or a human's muscle memory
65
78
  // gets a readable failure that names the migration, and nothing old keeps silently working.
@@ -73,7 +86,6 @@ const SIGNPOSTS: Record<string, string> = {
73
86
  blob: 'spex evidence put|get',
74
87
  issues: 'spex issue — ls (was: bare issues) · show · open · reply · close · promote; on|off|status → the `issues.enabled` key in spexcode.json; `issues nudge` → spex internal nudge',
75
88
  forge: 'spex issue links [--pending] [--store <host>] (--host is now --store)',
76
- dashboard: 'spex serve ui',
77
89
  new: 'spex session new',
78
90
  ls: 'spex session ls',
79
91
  watch: 'spex session watch',
@@ -173,7 +185,7 @@ async function withWatchEdge<T>(selectors: string[], intervalMs: number, body: (
173
185
  }
174
186
 
175
187
  async function resolveSelectorOrExit(selector: string): Promise<string> {
176
- if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch)'); process.exit(2) }
188
+ if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch | . for self)'); process.exit(2) }
177
189
  const { resolveClientSession } = await import('./client.js')
178
190
  const { sessionLabel } = await import('./sessions.js')
179
191
  const r = await resolveClientSession(selector)
@@ -309,6 +321,19 @@ if (cmd === 'serve') {
309
321
  console.error(`spex serve: unknown target '${target}' — spex serve [api] (the backend) | spex serve ui (the dashboard)`)
310
322
  process.exit(2)
311
323
  }
324
+ } else if (cmd === 'dashboard') {
325
+ // the HOST-level dashboard ([[host-gateway]]): ONE gateway for every project this user serves. The
326
+ // engine is [[gateway-hub]] (routing + [[gateway-auth]] authorization: admin scope implicit from
327
+ // loopback until an admin password is set; per-project gates as configured); the host layer mounts the
328
+ // instance-validated project registry, its SSE stream, the durable catalog, and the /projects
329
+ // operations (register · init · doctor · start a backend) as the hub's admin extension. No --api-port
330
+ // pairing: which backend a request reaches is named in its /p/:projectId path, resolved per request.
331
+ // `spex serve ui` remains the explicit one-backend pairing; this verb is the zero-config many-project face.
332
+ const { startHostDashboard } = await import('./host.js')
333
+ const port = Number(flag('port') ?? process.env.SPEXCODE_DASHBOARD_PORT ?? 5173)
334
+ const host = flag('host') ?? '127.0.0.1'
335
+ if (!Number.isInteger(port)) { console.error('spex dashboard: --port must be an integer'); process.exit(2) }
336
+ startHostDashboard({ port, host })
312
337
  } else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
313
338
  // `spex help <cmd>` drills into one command; bare help is the map. Both name the next layer down.
314
339
  const { commandHelp, overviewHelp } = await import('./help.js')
@@ -467,9 +492,9 @@ if (cmd === 'serve') {
467
492
  }
468
493
  } else if (cmd === 'init') {
469
494
  // scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
470
- // into <targetDir> (default cwd). spex init [targetDir] [--preset <tier>]
495
+ // into <targetDir> (default cwd). spex init [targetDir] --harness <ids> [--preset <tier>]
471
496
  const { specInit } = await import('./init.js')
472
- await specInit(positionals(3)[0], flag('preset'))
497
+ await specInit(positionals(3)[0], flag('preset'), flag('harness'))
473
498
  } else if (cmd === 'uninstall') {
474
499
  // the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
475
500
  // .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.plugins data or their own
@@ -497,42 +522,35 @@ if (cmd === 'serve') {
497
522
  if (!r.ok) { console.error(`no evals for ${id} (status ${r.status})`); process.exit(1) }
498
523
  if (has('json')) { console.log(JSON.stringify(r.model, null, 2)); await flushExit(0) }
499
524
  const m = r.model
500
- // mirror the tab's scenarioStates per node: latest reading per DECLARED scenario (evals arrive
501
- // newest-first), so a retired scenario's residual reading contributes no row; the ✦ count is over
502
- // these rows the same number the tab's chip shows.
503
- const groups = m.nodes.map((n) => {
504
- const latest = new Map<string, (typeof n.evals)[number]>()
505
- for (const e of n.evals) if (!latest.has(e.scenario)) latest.set(e.scenario, e)
506
- const blind = n.scenarios.filter((s) => !latest.has(s.name))
507
- const rows = n.scenarios.filter((s) => latest.has(s.name)).map((s) => latest.get(s.name)!)
508
- .sort((a, b) => (Number(b.inSession) - Number(a.inSession)) || (a.ts < b.ts ? 1 : -1))
509
- return { n, blind, rows }
510
- })
511
- const own = groups.reduce((a, g) => a + g.rows.filter((e) => e.inSession).length, 0)
512
- console.log(`eval ${m.title} [${m.id}]`)
513
- console.log(` branch : ${m.branch ?? '—'} · ${m.ahead} commit(s) ahead · ${m.dirtyNonRuntime} uncommitted`)
525
+ const byNode = new Map<string, any[]>()
526
+ for (const item of m.items) {
527
+ const rows = byNode.get(item.node) ?? []
528
+ rows.push(item)
529
+ byNode.set(item.node, rows)
530
+ }
531
+ const groups = [...byNode].map(([node, rows]) => ({ node, rows }))
532
+ const own = m.items.filter((item) => item.inSession).length
533
+ console.log(`eval session [${m.id}]`)
514
534
  console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
515
535
  if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = inherited baseline)`)
516
- if (!m.nodes.length) console.log('\n no changed spec nodes — nothing to evaluate yet (empty diff)')
517
- for (const { n, blind, rows } of groups) {
518
- console.log(`\n${n.title} [${n.id}]${n.uncoveredFrontend ? ' ⚠ frontend change with NO eval.md — a blind spot: give it a scenario' : ''}`)
519
- for (const s of blind) console.log(` ∅ unmeasured ${s.name} — declared, never measured (blind spot)`)
536
+ if (!m.items.length) console.log('\n no affected scenarios to evaluate yet')
537
+ for (const { node, rows } of groups) {
538
+ console.log(`\n${node}`)
539
+ for (const item of rows.filter((row) => row.filterKind === 'blind')) console.log(` ∅ unmeasured ${item.scenario} — declared, never measured (blind spot)`)
520
540
  let divided = false
521
- for (const e of rows) {
522
- if (!e.inSession && !divided && rows.some((x) => x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
541
+ for (const e of rows.filter((row) => row.filterKind === 'result')) {
542
+ if (!e.inSession && !divided && rows.some((x) => x.filterKind === 'result' && x.inSession)) { console.log(` ── inherited baseline (other sessions' latest evals) ──`); divided = true }
523
543
  const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
524
- const stale = e.fresh ? '' : ` (stale: ${e.staleAxes.join(',')})`
544
+ const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
525
545
  console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
526
546
  }
527
- if (!n.hasEvalFile && !n.uncoveredFrontend) console.log(' (no eval.md — nothing declared to measure)')
528
- else if (n.hasEvalFile && !n.scenarios.length) console.log(' (eval.md declares no scenarios)')
529
547
  }
530
- } else if (['add', 'ls', 'scenario', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
548
+ } else if (['add', 'ls', 'scenario', 'matrix', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
531
549
  // node-scoped verbs — thin route; the logic lives in spec-eval.
532
550
  const { runEval } = await import('../../spec-eval/src/cli.js')
533
551
  await flushExit(await runEval(process.argv.slice(3)))
534
552
  } else {
535
- console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | lint | ok | retract | clean (spex help eval)`)
553
+ console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls | matrix | lint | ok | retract | clean (spex help eval)`)
536
554
  if (!sub.startsWith('--')) console.error(` (the old \`spex eval <SEL>\` session read is now \`spex eval ls --session <SEL>\` [--export])`) // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
537
555
  process.exit(2)
538
556
  }
@@ -583,7 +601,14 @@ if (cmd === 'serve') {
583
601
  // trust, for cwd's project. Anchored on git-native events only ([[commit-surgery]]): this verb, init,
584
602
  // session-worktree creation, and the planted pre-commit/post-checkout/post-merge hooks.
585
603
  const { materialize } = await import('./materialize.js')
586
- console.log(`materialized — content-hash ${materialize()}`)
604
+ try {
605
+ console.log(`materialized — content-hash ${materialize().contentHash}`)
606
+ } catch (e) {
607
+ // a policy error (e.g. a missing/illegal `harnesses` set) is a user-facing verdict, not a crash — one
608
+ // line + the repair it already carries, never a stack trace.
609
+ console.error(`spex materialize: ${(e as Error).message}`)
610
+ process.exit(1)
611
+ }
587
612
  } else if (cmd === 'doctor') {
588
613
  // @@@ doctor - the diagnosis surface ([[doctor]], né `self` — renamed: "self" read as the tool itself /
589
614
  // the global install, while the report is about THIS agent's wiring): does the materialized workflow
@@ -599,12 +624,16 @@ if (cmd === 'serve') {
599
624
  } else if (SESSION_SIGNPOSTS[sub]) {
600
625
  signpost(`spex session ${sub}`, SESSION_SIGNPOSTS[sub])
601
626
  } else if (sub === 'new') {
602
- // spex session new "<prompt>" [--node X] (prompt = first positional or --prompt, or --prompt-file
627
+ // spex session new "<prompt>" (prompt = first positional or --prompt, or --prompt-file
603
628
  // <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
604
629
  // createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
605
630
  // it falls back to an in-process launch only when no backend answers.
631
+ if (has('node')) {
632
+ console.error('spex session new: --node was removed — put a [[<id>]] mention in the prompt — the first mention binds')
633
+ process.exit(2)
634
+ }
635
+ rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'api', 'port'])
606
636
  const { createSession } = await import('./sessions.js')
607
- if (has('harness')) { console.error('spex session new: --harness was removed; use --launcher <name> (for example --launcher codex)'); process.exit(2) }
608
637
  const promptFile = flag('prompt-file')
609
638
  const inline = flag('prompt') ?? positionals(4)[0]
610
639
  let prompt = inline ?? ''
@@ -616,8 +645,7 @@ if (cmd === 'serve') {
616
645
  catch (e) { console.error(`spex session new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
617
646
  if (!prompt.trim()) { console.error(`spex session new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
618
647
  }
619
- const nodeArg = flag('node')
620
- const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
648
+ const created = await createSession(prompt, flag('launcher') ?? undefined)
621
649
  console.log(JSON.stringify(created, null, 2))
622
650
  await launchMonitorReminder(created.id)
623
651
  } else if (sub === 'ls') {
@@ -728,7 +756,7 @@ if (cmd === 'serve') {
728
756
  // overrides for a genuinely wedged process. A following prompt is what actually re-drives it.
729
757
  const full = await resolveSelectorOrExit(id)
730
758
  const r = await c.clientResume(full, process.argv.includes('--force'))
731
- if (r.ok) console.log(`${full} -> resumed`)
759
+ if (r.ok) console.log(r.info ? `${full} -> ${r.info}` : `${full} -> resumed`)
732
760
  else { console.error(`spex session resume: ${r.error || `no such session ${full}`}`); process.exit(2) }
733
761
  } else if (sub === 'done') {
734
762
  // sugar for awaiting; --propose merge|nothing|close, optional --note
@@ -855,6 +883,18 @@ if (cmd === 'serve') {
855
883
  // line; GET /api/settings exposes the same resolution (`.layout`).
856
884
  const { mainBranch } = await import('./layout.js')
857
885
  console.log(mainBranch())
886
+ } else if (sub === 'spec-governors') {
887
+ // Stable machine projection for spec-aware hooks: one real code: governor per row, with the live spec
888
+ // path the block reason can point at. Empty stdout means ungoverned (including related-only).
889
+ const file = process.argv[4]
890
+ if (!file) { console.error('usage: spex internal spec-governors <path>'); process.exit(2) }
891
+ const { specOwners, loadSpecsLite } = await import('./specs.js')
892
+ const paths = new Map(loadSpecsLite().map((node) => [node.id, node.path]))
893
+ for (const owner of specOwners(file)) {
894
+ const path = paths.get(owner.id)
895
+ if (!path) throw new Error(`governor '${owner.id}' has no live spec path`)
896
+ console.log(`${owner.id}\t${path}`)
897
+ }
858
898
  } else if (sub === 'codex-launch') {
859
899
  // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
860
900
  // (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
@@ -868,7 +908,7 @@ if (cmd === 'serve') {
868
908
  // On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
869
909
  // the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
870
910
  // the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
871
- const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex --yolo'))
911
+ const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex'))
872
912
  const r = await codexStartThread(sock, cwd, bypassHookTrust)
873
913
  if (!r.ok) { console.error(r.error); process.exit(1) }
874
914
  if (prompt) {
@@ -887,6 +927,17 @@ if (cmd === 'serve') {
887
927
  const sid = process.env.SPEXCODE_SESSION_ID
888
928
  if (sid) markHarnessSessionId(sid, r.threadId)
889
929
  console.log(r.threadId)
930
+ } else if (sub === 'opencode-capture') {
931
+ // opencode MINTS its own session id (no launch flag pins it), so the generated plugin's FIRST event calls
932
+ // this to store that id as harness_session_id on the governed record (SPEXCODE_SESSION_ID from the launch
933
+ // env, inherited by the opencode process → plugin). That is what lets reopen() resume the SAME
934
+ // conversation (`--session <id>`). A missing record/env is a clean no-op — a plugin loaded outside a
935
+ // governed launch has nothing to mark.
936
+ const { markHarnessSessionId } = await import('./sessions.js')
937
+ const ocid = process.argv[4]
938
+ if (!ocid) { console.error('usage: spex internal opencode-capture <opencode-session-id>'); process.exit(2) }
939
+ const sid = process.env.SPEXCODE_SESSION_ID
940
+ console.log(sid && markHarnessSessionId(sid, ocid) ? `captured ${ocid}` : 'noop (no governed session record)')
890
941
  } else if (sub === 'commit-surgery') {
891
942
  // the pre-commit footprint anchor ([[commit-surgery]]): unconditional materialize + staged-index repair
892
943
  // (strip our sentinel block from staged blobs, unstage HEAD-untracked generated artifacts). Called only
@@ -1,5 +1,4 @@
1
1
  import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
2
- import type { SessionEvals } from '../../spec-eval/src/sessioneval.js'
3
2
 
4
3
  export class BackendError extends Error {
5
4
  constructor(message: string, readonly status?: number) {
@@ -74,15 +73,38 @@ export async function clientEvalExport(id: string): Promise<ExportResult> {
74
73
  return { ok: false, status: r.status }
75
74
  }
76
75
 
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
- // nodes' worktree-rooted reading rows (each carrying `inSession`), no diff enrichment, no inlined evidence
80
- // bytes — what `spex eval` renders, the dashboard Eval tab's source. 404 → no such session.
81
- export type EvalsResult = { ok: true; model: SessionEvals } | { ok: false; status: number }
76
+ // The CLI's explicit aggregate walks the same 25-row pages as the dashboard. No server response contains
77
+ // the full session model; aggregation exists only for this one-shot terminal rendering.
78
+ type SessionEvalPage = {
79
+ items: any[]
80
+ page: number
81
+ pageCount: number
82
+ total: number
83
+ gates: any[]
84
+ unknown: number
85
+ revision: string
86
+ summary?: any
87
+ evalRevision?: any
88
+ }
89
+ export type EvalsResult = { ok: true; model: SessionEvalPage & { id: string } } | { ok: false; status: number }
82
90
  export async function clientEvals(id: string): Promise<EvalsResult> {
83
- const r = await apiFetch(`/api/sessions/${seg(id)}/evals`)
84
- if (!r.ok) return { ok: false, status: r.status }
85
- return { ok: true, model: await r.json() as SessionEvals }
91
+ const q = encodeURIComponent(`is:eval scope:${id}`)
92
+ for (let attempt = 0; attempt < 2; attempt++) {
93
+ const items: any[] = []
94
+ let first: SessionEvalPage | null = null
95
+ let changed = false
96
+ for (let page = 1;; page++) {
97
+ const r = await apiFetch(`/api/evals?q=${q}&page=${page}`)
98
+ if (!r.ok) return { ok: false, status: r.status }
99
+ const current = await r.json() as SessionEvalPage
100
+ first ??= current
101
+ if (current.revision !== first.revision) { changed = true; break }
102
+ items.push(...current.items)
103
+ if (page >= current.pageCount) break
104
+ }
105
+ if (!changed) return { ok: true, model: { ...first!, id, items } }
106
+ }
107
+ throw new BackendError(`session eval pages changed while reading ${id}; retry the command`)
86
108
  }
87
109
 
88
110
  // POST /api/sessions/:id/merge — the cockpit's merge DISPATCH (200 {dispatched:true} / 409 {reason}).
@@ -94,8 +116,9 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
94
116
 
95
117
  // POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
96
118
  // working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
97
- // `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
98
- export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
119
+ // `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404). `info`
120
+ // carries a non-error advisory.
121
+ export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
99
122
  await guarded('session resume')
100
123
  const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
101
124
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
@@ -1,19 +1,17 @@
1
- // @@@ spex doctor - the DIAGNOSIS surface ([[doctor]]; command renamed from `self`, which misread as the
2
- // tool itself / the global install). When a user launches their OWN claude/codex with no SpexCode process
3
- // in the launch, the workflow reaches that agent only through the files materialize() writes (the manifest
4
- // in the global store; the in-tree contract blocks + hook shims + codex trust). Bare `spex doctor` answers
5
- // "is this agent actually governed, or silently running free?" — diagnosing that materialized contract per
6
- // LAYER, looping the same HARNESSES adapter materialize delivers through (so claude AND codex are covered
7
- // with no hardcoded paths). It catches the SILENT failure: a shim whose handler is missing, a PATH that
8
- // can't resolve `spex`, a contract that never landed. Read-only today: the bare report, `contract` (print
9
- // the surface:system text any agent reads), `conflicts`. install/uninstall are STAGED (noteStaged).
1
+ // @@@ spex doctor - the opt-in, read-only project DIAGNOSIS surface ([[doctor]]). Bare doctor composes
2
+ // spec-health findings with the delivery audit: when a user launches their OWN agent with no SpexCode
3
+ // process in the launch, the workflow reaches it only through the files materialize() writes. The audit
4
+ // loops the same HARNESSES adapter materialize uses and catches missing delivery or duplicate discovery.
5
+ // `--contract` and `--conflicts` remain focused representations of that same diagnosis.
10
6
  import { existsSync, readFileSync, readdirSync, accessSync, constants } from 'node:fs'
11
7
  import { join, dirname, basename } from 'node:path'
12
8
  import { fileURLToPath } from 'node:url'
13
9
  import { execFileSync } from 'node:child_process'
14
10
  import { homedir } from 'node:os'
15
- import { loadSystemConfig, loadSkillConfig } from './specs.js'
16
- import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout } from './layout.js'
11
+ import { loadSystemConfig, loadSkillConfig, loadSpecs } from './specs.js'
12
+ import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from './layout.js'
13
+ import { loadConfig } from './lint.js'
14
+ import { trackedSourceFiles } from './source-files.js'
17
15
 
18
16
  // this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
19
17
  // materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
@@ -31,6 +29,156 @@ function commonHooksDir(dir: string): string | null {
31
29
  }
32
30
  const read = (f: string): string => { try { return readFileSync(f, 'utf8') } catch { return '' } }
33
31
 
32
+ type AltitudeConfig = {
33
+ lineBudget: number
34
+ charBudget: number
35
+ sizeable: number
36
+ dense: number
37
+ steps: number
38
+ identifierExtensions: string[]
39
+ }
40
+
41
+ type BreadthConfig = {
42
+ maxChildren: number
43
+ }
44
+
45
+ export type HealthFinding = {
46
+ check: string
47
+ spec: string
48
+ summary: string
49
+ evidence: string[]
50
+ repair: string
51
+ }
52
+
53
+ const DEFAULT_ALTITUDE: AltitudeConfig = {
54
+ lineBudget: 50,
55
+ charBudget: 4200,
56
+ sizeable: 35,
57
+ dense: 1.3,
58
+ steps: 3,
59
+ identifierExtensions: [],
60
+ }
61
+
62
+ const DEFAULT_BREADTH: BreadthConfig = {
63
+ maxChildren: 8,
64
+ }
65
+
66
+ function loadAltitudeConfig(root: string): AltitudeConfig {
67
+ const configured = readJsonConfig(join(root, 'spexcode.json'))?.doctor?.altitude ?? {}
68
+ const merged = { ...DEFAULT_ALTITUDE, ...configured }
69
+ return {
70
+ ...merged,
71
+ identifierExtensions: (merged.identifierExtensions ?? []).map((ext: string) => ext.replace(/^\.+/, '')),
72
+ }
73
+ }
74
+
75
+ function loadBreadthConfig(root: string): BreadthConfig {
76
+ const configured = readJsonConfig(join(root, 'spexcode.json'))?.doctor?.breadth ?? {}
77
+ return { ...DEFAULT_BREADTH, ...configured }
78
+ }
79
+
80
+ // Filename rows are lint coverage's exact tracked candidates. Compatibility extensions lower to wildcard
81
+ // rows before the one identifier matcher is compiled.
82
+ function identifierFilenameCandidates(sourceFiles: string[], compatibilityExtensions: string[]): string[] {
83
+ return [...new Set([
84
+ ...sourceFiles.map((path) => basename(path)),
85
+ ...compatibilityExtensions.map((ext) => `*.${ext}`),
86
+ ])]
87
+ }
88
+
89
+ function identRe(filenameCandidates: string[]): RegExp {
90
+ const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
91
+ const filenames = filenameCandidates
92
+ .map((candidate) => candidate.startsWith('*.')
93
+ ? `[\\w-]+\\.${escape(candidate.slice(2))}`
94
+ : escape(candidate))
95
+ .sort((a, b) => b.length - a.length)
96
+ const signals = [
97
+ '[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*',
98
+ '\\b[a-z]+_[a-z0-9_]+\\b',
99
+ '\\b\\w+\\(',
100
+ '`[^`]+`',
101
+ '\\/[\\w./-]+\\.\\w+',
102
+ ]
103
+ if (filenames.length) signals.push(`(?<![\\w./-])(?:${filenames.join('|')})(?![\\w.-])`)
104
+ return new RegExp(signals.join('|'), 'g')
105
+ }
106
+
107
+ const STEP_LINE = /^\s*(\d+[.)]\s|[-*]\s*(first|then|next|finally)\b)|(^|[,;]\s*)(first|then|next|finally),/i
108
+
109
+ function altitudeEvidence(body: string, cfg: AltitudeConfig, ident: RegExp): string[] {
110
+ const lines = body.split('\n')
111
+ const nonBlank = lines.filter((line) => line.trim()).length
112
+ let inFence = false
113
+ let signals = 0
114
+ let steps = 0
115
+ for (const line of lines) {
116
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue }
117
+ if (inFence || !line.trim()) continue
118
+ signals += line.match(ident)?.length ?? 0
119
+ if (STEP_LINE.test(line)) steps++
120
+ }
121
+ const density = signals / Math.max(1, nonBlank)
122
+ const evidence: string[] = []
123
+ if (nonBlank > cfg.lineBudget || body.length > cfg.charBudget)
124
+ evidence.push(`${nonBlank} non-blank lines / ${body.length} chars over budget (${cfg.lineBudget}/${cfg.charBudget})`)
125
+ if (nonBlank > cfg.sizeable && density > cfg.dense)
126
+ evidence.push(`code-identifier density ${density.toFixed(2)}/line over ${cfg.dense}`)
127
+ if (nonBlank > cfg.sizeable && steps >= cfg.steps)
128
+ evidence.push(`${steps} step-by-step how-to lines`)
129
+ return evidence
130
+ }
131
+
132
+ export async function specHealthDiagnosis(root: string): Promise<HealthFinding[]> {
133
+ const lint = loadConfig(root)
134
+ const altitude = loadAltitudeConfig(root)
135
+ const breadth = loadBreadthConfig(root)
136
+ const governed = trackedSourceFiles(root, lint.governedRoots, lint)
137
+ const ident = identRe(identifierFilenameCandidates(governed, altitude.identifierExtensions))
138
+ const specs = await loadSpecs(root)
139
+ const childCount = new Map<string, number>()
140
+ for (const spec of specs) if (spec.parent) childCount.set(spec.parent, (childCount.get(spec.parent) ?? 0) + 1)
141
+ const findings: HealthFinding[] = []
142
+ for (const spec of specs) {
143
+ const evidence = altitudeEvidence(spec.body, altitude, ident)
144
+ if (evidence.length) findings.push({
145
+ check: 'altitude',
146
+ spec: spec.id,
147
+ summary: 'body reads like mechanics rather than a contract',
148
+ evidence,
149
+ repair: `rewrite '${spec.id}' around observable intent and invariants; the tidy workflow can perform the semantic review`,
150
+ })
151
+ const children = childCount.get(spec.id) ?? 0
152
+ if (children >= breadth.maxChildren) findings.push({
153
+ check: 'breadth',
154
+ spec: spec.id,
155
+ summary: 'tree fan-out may be missing a natural grouping layer',
156
+ evidence: [`${children} direct child nodes (>= ${breadth.maxChildren})`],
157
+ repair: `review '${spec.id}' with the regroup workflow; group only along a real seam and leave genuine peers flat`,
158
+ })
159
+ }
160
+ return findings
161
+ }
162
+
163
+ function healthReport(findings: HealthFinding[], adopted: boolean): string[] {
164
+ const lines = ['Spec health diagnosis (opt-in advisory; never part of spex spec lint)']
165
+ if (!adopted) {
166
+ lines.push(' status : unavailable — adopt the repository with `spex init`')
167
+ return lines
168
+ }
169
+ const checks = ['altitude', 'breadth', ...new Set(findings.map((finding) => finding.check).filter((check) => check !== 'altitude' && check !== 'breadth'))]
170
+ for (const check of checks) {
171
+ const rows = findings.filter((finding) => finding.check === check)
172
+ lines.push(` ${check.padEnd(16)}: ${rows.length ? `${rows.length} finding(s)` : 'healthy'}`)
173
+ for (const finding of rows) {
174
+ lines.push(` ${finding.spec.padEnd(16)}: ${finding.summary}`)
175
+ for (const evidence of finding.evidence) lines.push(` evidence : ${evidence}`)
176
+ lines.push(` repair : ${finding.repair}`)
177
+ }
178
+ }
179
+ return lines
180
+ }
181
+
34
182
  // @@@ contractText - the layer-2 payload: the composed `surface:system` bodies, the SAME join materialize()
35
183
  // folds into each harness's contract file — so a BYOA agent reads byte-identical guidance.
36
184
  function contractText(): { names: string[]; body: string } {
@@ -170,9 +318,9 @@ async function doubleDeliveryReport(base: string): Promise<{ lines: string[]; co
170
318
  if (conflict) {
171
319
  L.push('Repair — SpexCode is reaching this agent through MORE THAN ONE discovery channel. Keep exactly one:')
172
320
  L.push(' • remove the independently-installed plugin bundle (delete its dir, or `claude plugin uninstall spexcode`); or')
173
- L.push(' • if you WANT the plugin, stop the native delivery: set spexcode.json "harnesses" to a plugin target')
174
- L.push(' (e.g. ["plugin",{"plugin":".claude"}] {"plugin":".claude"}) so `spex materialize` prunes the loose shim/contract/skills; or')
175
- L.push(' • remove the loose copy directly (`spex doctor uninstall` [staged] / `spex uninstall`).')
321
+ L.push(' • if you WANT the plugin, stop the native delivery: set spexcode.json to')
322
+ L.push(' `"harnesses": [{"plugin":".claude"}]`, then run `spex materialize` to prune the loose shim/contract/skills; or')
323
+ L.push(' • remove SpexCode\'s generated delivery with `spex uninstall`.')
176
324
  } else {
177
325
  L.push('No double-delivery: each harness is reached by at most one spexcode-stamped channel.')
178
326
  }
@@ -233,8 +381,14 @@ async function doctor(): Promise<number> {
233
381
  })
234
382
  line('issues workflow', `${issuesEnabled() ? 'ON' : 'OFF'} (spexcode.json issues.enabled)`)
235
383
  if (legacy.length) line(' LEGACY key', `\`proposals\` found in ${legacy.map((f) => join(cfgHome, f)).join(', ')} — no longer read; rename it to "issues": { "enabled": … }`)
384
+ const projectConfig = readJsonConfig(join(base, 'spexcode.json'))
385
+ if (Object.prototype.hasOwnProperty.call(projectConfig.lint ?? {}, 'maxChildren'))
386
+ line('RETIRED key', '`lint.maxChildren` is no longer read — move the value to `doctor.breadth.maxChildren`')
236
387
  }
237
388
 
389
+ const health = adopted ? await specHealthDiagnosis(base) : []
390
+ L.push('\n' + healthReport(health, adopted).join('\n'))
391
+
238
392
  // --- preconditions: nothing downstream fires without these ---
239
393
  L.push('\nPreconditions (without these nothing downstream fires)')
240
394
  for (const bin of ['spex', 'claude', 'codex']) {
@@ -347,37 +501,23 @@ async function conflicts(): Promise<number> {
347
501
  return conflict ? 1 : 0
348
502
  }
349
503
 
350
-
351
- // install/uninstall are STAGED: wiring layer-3 hooks into a standalone repo is only SAFE once the hooks
352
- // detect a missing managed session and degrade. So the diagnosis ships first; the installer lands behind it.
353
- function noteStaged(verb: string): number {
354
- console.error(`spex doctor ${verb} is not available yet — it is staged behind the hook-degradation prerequisite
355
- (the live hooks must detect a missing managed session and degrade before they can be safely wired into your
356
- own agent's config). Meanwhile: \`spex doctor\` reports your coverage, and \`spex doctor --contract\` prints
357
- the workflow text you can hand any agent.`)
504
+ function migrationRemoved(): number {
505
+ console.error('spex: `spex doctor --migrate` was removed in v0.4.0 — migrate this tree with a 0.3.x SpexCode release, then reinstall the current release. Nothing was changed.')
358
506
  return 2
359
507
  }
360
508
 
361
509
  function usage(): number {
362
- console.error(`spex doctor — diagnose how the SpexCode workflow reaches your agent
363
- (bare) per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
510
+ console.error(`spex doctor — diagnose spec health and how the SpexCode workflow reaches your agent
511
+ (bare) spec-health findings + delivery report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
364
512
  --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`)
513
+ --conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)`)
371
514
  return 0
372
515
  }
373
516
 
374
517
  export async function runDoctor(args: string[]): Promise<number> {
375
518
  // contract/conflicts are FLAGS, not subcommands ([[cli-surface]] §4: another representation of the same
376
519
  // 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()
520
+ if (args.includes('--migrate')) return migrationRemoved()
381
521
  if (args.includes('--contract')) return contract()
382
522
  if (args.includes('--conflicts')) return await conflicts()
383
523
  switch (args[0]) {
@@ -385,8 +525,6 @@ export async function runDoctor(args: string[]): Promise<number> {
385
525
  case 'contract': case 'conflicts':
386
526
  console.error(`spex: \`spex doctor ${args[0]}\` was removed in v0.3.0 — use: spex doctor --${args[0]}`)
387
527
  return 2
388
- case 'install': return noteStaged('install')
389
- case 'uninstall': return noteStaged('uninstall')
390
528
  case 'help': case '--help': case '-h': return usage()
391
529
  default: console.error(`spex doctor: unknown subcommand "${args[0]}"`); usage(); return 2
392
530
  }