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
@@ -6,7 +6,9 @@ import { createConnection, type Socket } from 'node:net'
6
6
  import { execFile, execFileSync } from 'node:child_process'
7
7
  import { promisify } from 'node:util'
8
8
  import { fileURLToPath } from 'node:url'
9
- import { claudeSlashCommands, codexSlashCommands, type SlashCommand } from './slash-commands.js'
9
+ import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlashCommands, type SlashCommand } from './slash-commands.js'
10
+ import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js'
11
+ import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
10
12
  import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
11
13
  import { git } from './git.js'
12
14
 
@@ -22,7 +24,7 @@ import { git } from './git.js'
22
24
  // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
23
25
  // (materialize writes every harness's artifacts).
24
26
 
25
- export type HarnessId = 'claude' | 'codex'
27
+ export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi'
26
28
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
27
29
  // the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
28
30
  // the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
@@ -54,7 +56,6 @@ export interface Harness {
54
56
  // instead of showing the folder name. This is the ONLY harness branch in the headline path: the capability
55
57
  // is data on the adapter, not an `if (codex)` in sessions.ts.
56
58
  readonly paneTitleIsSelfSummary: boolean
57
-
58
59
  // --- launch / sessionId ---
59
60
  // the base agent command. Claude: `claude …`; Codex starts a project-scoped app-server and launches the
60
61
  // visible TUI with `--remote` pointed at it. `cmd` is the SESSION's persisted launcher command
@@ -101,16 +102,18 @@ export interface Harness {
101
102
  // ONLY place agent-surface divergence lives (the skillDir analog). Claude reads .claude/agents/<name>.md;
102
103
  // Codex has no file-discovered agent-definition primitive, so it returns null and materialize skips it.
103
104
  agentDir(proj: string): string | null
104
- // the shim payload: the settings/hooks JSON binding every event the dispatcher (harness id baked in), and
105
- // the per-event command string (shared with the trust writer so they hash identically).
106
- shim(dispatch: string, spex: string): { json: string; cmd: (e: string) => string }
105
+ // the shim payload: `content` is whatever artifact THIS harness auto-discovers to wire every event to the
106
+ // dispatcher (harness id baked in) a settings/hooks JSON for claude/codex, a generated event-bus PLUGIN
107
+ // for opencode, a generated TypeScript EXTENSION for pi plus the per-event command string (shared with
108
+ // the trust writer so they hash identically).
109
+ shim(dispatch: string, spex: string): { content: string; cmd: (e: string) => string }
107
110
  // make a dispatched/self-launched agent run the hooks with zero prompts. Codex writes PROJECT trust — and, on
108
111
  // a binary without `--dangerously-bypass-hook-trust`, per-hook trusted_hash blocks — into the GLOBAL
109
112
  // ~/.codex/config.toml (codex's security model: trust is global-only). PROJECT trust is UNCONDITIONAL: it
110
113
  // ENABLES the project config layer so codex discovers our hooks at all, a tier bypass_hook_trust does NOT
111
114
  // cover. Claude is a no-op (it relies on folder-trust). `cmdFor` MUST be the same per-event command the shim
112
115
  // emitted.
113
- writeTrust(proj: string, cmdFor: (e: string) => string): void
116
+ writeTrust(proj: string, cmdFor: (e: string) => string): readonly string[]
114
117
 
115
118
  // --- the `/` menu ---
116
119
  // the slash-command list, computed the way THIS harness computes its own `/` menu.
@@ -421,7 +424,7 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
421
424
  bypassProbe.set(binary, ok)
422
425
  return ok
423
426
  }
424
- export function codexLaunchCommand(_id: string, codexCmd = 'codex --yolo', serverCmd?: string, dir = runtimeRoot()): string {
427
+ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot()): string {
425
428
  const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
426
429
  // The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
427
430
  // the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
@@ -756,13 +759,18 @@ export function codexTurn(sock: string, threadId: string, text: string, cwd?: st
756
759
  // rollout just lands a few seconds LATE (not lost). Handing the id to `resume` before then is the "no rollout
757
760
  // found for thread id" failure, so codex-launch WAITS for the rollout to land before it trusts the id.
758
761
  const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
759
- // does a rollout file for this thread id exist yet? Rollouts are grouped by date, so scan only the newest few
760
- // day-dirs (lexical order = chronological on zero-padded YYYY/MM/DD) — clock-agnostic and cheap on a big history.
762
+ // does a rollout file for this thread id exist yet? Rollouts are grouped by date; walk day-dirs newest-first
763
+ // (lexical order = chronological on zero-padded YYYY/MM/DD) and return on first hit — the fresh rollout lives in
764
+ // the newest real dir, so the common case reads one dir. The walk is exhaustive, never capped at "the newest few
765
+ // dirs": future-dated junk under sessions/ (a test once planted 2099/12/* in the real CODEX_HOME) sorts above
766
+ // every real day-dir, and a cap let three such dirs mask ALL real rollouts — every codex launch then failed
767
+ // "persisted no rollout" with the rollout sitting on disk. A full walk is a readdir per day-dir — still cheap.
761
768
  export function codexRolloutExists(threadId: string, root = codexSessionsDir()): boolean {
762
769
  const kids = (d: string) => { try { return readdirSync(d).sort().reverse() } catch { return [] as string[] } }
763
- const days: string[] = []
764
- for (const y of kids(root)) { for (const m of kids(join(root, y))) { for (const d of kids(join(root, y, m))) { days.push(join(root, y, m, d)); if (days.length >= 3) break } if (days.length >= 3) break } if (days.length >= 3) break }
765
- return days.some((d) => kids(d).some((f) => f.includes(threadId)))
770
+ for (const y of kids(root)) for (const m of kids(join(root, y))) for (const d of kids(join(root, y, m))) {
771
+ if (kids(join(root, y, m, d)).some((f) => f.includes(threadId))) return true
772
+ }
773
+ return false
766
774
  }
767
775
  // poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
768
776
  // caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
@@ -846,11 +854,11 @@ export function removeManagedBlock(file: string, comment: readonly [string, stri
846
854
  // the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
847
855
  // baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
848
856
  // the cli-needing handlers.
849
- function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): { json: string; cmd: (e: string) => string } {
857
+ function buildShim(id: HarnessId, events: readonly string[], dispatch: string, spex: string): { content: string; cmd: (e: string) => string } {
850
858
  const cmd = (e: string) => `SPEX='${spex}' bash ${dispatch} ${id} ${e}`
851
859
  const hooks: Record<string, unknown> = {}
852
860
  for (const e of events) hooks[e] = [{ hooks: [{ type: 'command', command: cmd(e) }] }]
853
- return { json: JSON.stringify({ hooks }, null, 2), cmd }
861
+ return { content: JSON.stringify({ hooks }, null, 2), cmd }
854
862
  }
855
863
 
856
864
  // ---------------------------------------------------------------------------------------------------------
@@ -909,7 +917,7 @@ function stripCodexTrustFor(cur: string, proj: string, hooksJson: string): strin
909
917
  // leave a DUPLICATE key (which breaks codex config loading) and self-heals a config that already carried one.
910
918
  // Scoped to THIS project path; never touches the user's other config. CODEX_HOME respected for testability.
911
919
  // (`events` may be empty for a trust-only stamp in tests.)
912
- export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): void {
920
+ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor: (e: string) => string): string {
913
921
  const home = process.env.CODEX_HOME || join(homedir(), '.codex')
914
922
  const file = join(home, 'config.toml')
915
923
  const hooksJson = join(proj, '.codex', 'hooks.json')
@@ -922,6 +930,7 @@ export function writeCodexTrust(proj: string, events: readonly string[], cmdFor:
922
930
  const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson)
923
931
  if (!existsSync(home)) mkdirSync(home, { recursive: true })
924
932
  writeFileSync(file, cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`)
933
+ return file
925
934
  }
926
935
 
927
936
  // the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
@@ -986,7 +995,9 @@ function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
986
995
  // (tmux/ps couldn't report) is not-live, and the caller's boot grace still shows a fresh launch — whose tree
987
996
  // may not yet contain codex — as 'starting', not 'offline'.
988
997
  const CODEXISH = /^(codex|node)/i // the vendored binary ('codex', 'codex-x86_64…') or the CLI's node runtime
989
- export function paneTreeRunsCodex(pane?: PaneProbe): boolean {
998
+ // the shared descendant-tree walk: does a process matching `re` live BELOW the pane pid? (The pane pid itself
999
+ // is the shell, so descendants only.) Pure over the caller's one ps snapshot.
1000
+ function paneTreeRuns(pane: PaneProbe | undefined, re: RegExp): boolean {
990
1001
  if (!pane?.panePid || !pane.procs?.size) return false
991
1002
  const kids = new Map<number, number[]>()
992
1003
  for (const [pid, p] of pane.procs) {
@@ -996,24 +1007,75 @@ export function paneTreeRunsCodex(pane?: PaneProbe): boolean {
996
1007
  while (stack.length) {
997
1008
  const pid = stack.pop()!
998
1009
  const comm = pane.procs.get(pid)?.comm ?? ''
999
- if (CODEXISH.test(comm.slice(comm.lastIndexOf('/') + 1))) return true // basename — macOS ps comm is a full path
1010
+ if (re.test(comm.slice(comm.lastIndexOf('/') + 1))) return true // basename — macOS ps comm is a full path
1000
1011
  const c = kids.get(pid); if (c) stack.push(...c)
1001
1012
  }
1002
1013
  return false
1003
1014
  }
1015
+ export function paneTreeRunsCodex(pane?: PaneProbe): boolean { return paneTreeRuns(pane, CODEXISH) }
1016
+
1017
+ // ONE whole-box pid→(ppid, comm) snapshot (a single `ps` spawn) — the table paneTreeRuns walks. Owned here
1018
+ // (beside its consumers) and shared with sessions.ts's liveSnapshot, so the two probe layers can never parse
1019
+ // ps differently. A failed/timed-out ps returns an empty table: the callers read that as not-provably-running.
1020
+ export async function procSnapshot(timeoutMs = 4000): Promise<ProcTable> {
1021
+ const t: ProcTable = new Map()
1022
+ let out = ''
1023
+ try { ({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: timeoutMs, killSignal: 'SIGKILL' })) } catch { return t }
1024
+ for (const line of out.split('\n')) {
1025
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
1026
+ if (m) t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() })
1027
+ }
1028
+ return t
1029
+ }
1004
1030
 
1005
1031
  // ---------------------------------------------------------------------------------------------------------
1006
1032
  // the two implementations.
1007
1033
 
1008
1034
  const CLAUDE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
1009
1035
  const CODEX_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
1036
+ // the five claude-shaped events pi's generated extension SYNTHESIZES from its own lifecycle (session_start →
1037
+ // SessionStart, input → UserPromptSubmit, tool_call → PreToolUse, tool_result → PostToolUse, agent_end +
1038
+ // agent_settled → Stop). pi has no idle/attention or failed-stop event → no Notification/StopFailure, same
1039
+ // real gap as codex.
1040
+ const PI_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
1010
1041
 
1011
1042
  // the resolved base launcher command per harness (the wrapper that sets the config-dir env), shared by
1012
- // launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The bare default is only
1013
- // the fallback for a truly-old record with NO pinned cmd and NO launcher name — there is no env/config-field
1014
- // resolution, because claude/codex are ordinary named launchers now ([[launcher-select]]), resolved by name.
1015
- const claudeBaseCmd = (cmd?: string) => cmd || 'claude --dangerously-skip-permissions'
1016
- const codexBaseCmd = (cmd?: string) => cmd || 'codex --yolo'
1043
+ // launchCmd and baseCmd so the two never diverge: the launcher's pinned `cmd` wins. The plain command is only
1044
+ // the fallback for a truly-old record with NO pinned cmd and NO launcher name — compatibility must preserve
1045
+ // the harness's normal permission model, never silently introduce an automatic-permission flag. There is no
1046
+ // env/config-field resolution because launchers are ordinary named config entries ([[launcher-select]]).
1047
+ const claudeBaseCmd = (cmd?: string) => cmd || 'claude'
1048
+ const codexBaseCmd = (cmd?: string) => cmd || 'codex'
1049
+ const piBaseCmd = (cmd?: string) => cmd || 'pi' // pi runs tools without permission prompts — no yolo flag exists or is needed
1050
+ const opencodeBaseCmd = (cmd?: string) => cmd || 'opencode'
1051
+
1052
+ // @@@ opencodeLaunchCommand - the tail-branching launch script (the codex marker pattern, minus any server:
1053
+ // opencode is a per-session process like claude). The caller-appended tail ("$@") is EITHER one single-quoted
1054
+ // prompt arg (a NEW launch → `--prompt`), or a resume marker from opencodeHarness.resumeArg: `--resume <id>`
1055
+ // re-attaches the owned opencode session (`--session <id>`, the SAME conversation), `--continue` re-attaches
1056
+ // the worktree's last session when no id was ever captured (the plugin failed before its first event). A new
1057
+ // launch's tail can never BE a literal marker (it's one quoted prompt), so the branch is unambiguous.
1058
+ export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
1059
+ const script = [
1060
+ `if [ "\${1:-}" = "--resume" ]; then`,
1061
+ // the marker carries the owned session id — export it so the plugin can seed rootSession at load: a
1062
+ // resumed session re-fires NO bus event until poked, so without this the rendezvous daemon rejects
1063
+ // every delivery (resume-continuity A-side: continuity ✓, steerability ✗).
1064
+ ` export SPEXCODE_OPENCODE_RESUME_ID="$2"`,
1065
+ ` exec ${opencodeCmd} --session "$2"`,
1066
+ `elif [ "\${1:-}" = "--continue" ]; then`,
1067
+ // no owned id to seed — mark the continue-resume so the plugin knows to ask the SDK for the
1068
+ // reattached session (scoped to this marker so a FRESH launch can never adopt a stale session).
1069
+ ` export SPEXCODE_OPENCODE_CONTINUE=1`,
1070
+ ` exec ${opencodeCmd} --continue`,
1071
+ `elif [ -n "\${1:-}" ]; then`,
1072
+ ` exec ${opencodeCmd} --prompt "$1"`,
1073
+ `else`,
1074
+ ` exec ${opencodeCmd}`,
1075
+ `fi`,
1076
+ ].join('\n')
1077
+ return `bash -lc ${shQuote(script)} spexcode-opencode`
1078
+ }
1017
1079
 
1018
1080
  export const claudeHarness: Harness = {
1019
1081
  id: 'claude',
@@ -1030,7 +1092,7 @@ export const claudeHarness: Harness = {
1030
1092
  skillDir: (proj) => join(proj, '.claude', 'skills'),
1031
1093
  agentDir: (proj) => join(proj, '.claude', 'agents'),
1032
1094
  shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
1033
- writeTrust: () => { /* Claude relies on folder-trust — nothing to write */ },
1095
+ writeTrust: () => [], // Claude relies on folder-trust — no artifact to report
1034
1096
  removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
1035
1097
  clean(proj, arts) { cleanHarness(this, proj, arts) },
1036
1098
  slashCommands: claudeSlashCommands,
@@ -1097,7 +1159,7 @@ export const codexHarness: Harness = {
1097
1159
  // the prompt and the worker runs unattended. bypass_hook_trust stays on `thread/start` + the resume flag
1098
1160
  // as DEFENCE for the non-resume paths (and if a version bump makes a hash mismatch, the app-server
1099
1161
  // thread still runs the hooks); it does not REPLACE the hashes here.
1100
- writeTrust: (proj, cmdFor) => writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor),
1162
+ writeTrust: (proj, cmdFor) => [writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor)],
1101
1163
  // trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
1102
1164
  removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
1103
1165
  clean(proj, arts) { cleanHarness(this, proj, arts) },
@@ -1121,8 +1183,93 @@ export const codexHarness: Harness = {
1121
1183
  resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
1122
1184
  }
1123
1185
 
1186
+ // @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
1187
+ // the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
1188
+ // lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
1189
+ // hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
1190
+ // extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
1191
+ // dispatch.sh AND binds this session's rendezvous socket itself (sessions.ts already exports
1192
+ // CLAUDE_BG_RENDEZVOUS_SOCK to every ownsRendezvous launch) speaking the reclaude line protocol — so
1193
+ // deliverViaRendezvous and the socket-listener liveness work UNCHANGED. Trust: pi gates project-local
1194
+ // extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
1195
+ // checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
1196
+ // one-run defence. See pi-harness.ts for the extension source + trust mechanics.
1197
+ export const piHarness: Harness = {
1198
+ id: 'pi',
1199
+ events: PI_EVENTS,
1200
+ ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
1201
+ paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
1202
+ launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
1203
+ baseCmd: piBaseCmd,
1204
+ sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
1205
+ sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
1206
+ shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
1207
+ worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
1208
+ contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
1209
+ skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
1210
+ agentDir: () => null, // pi has no file-discovered sub-agent primitive — materialize skips it
1211
+ shim: (dispatch, spex) => ({
1212
+ content: piExtensionSource(dispatch, spex),
1213
+ cmd: (e: string) => `SPEX='${spex}' bash ${dispatch} pi ${e}`, // what the extension actually spawns, for parity with buildShim
1214
+ }),
1215
+ writeTrust: (proj) => [writePiTrust(mainCheckout(proj))], // trust keys on the MAIN checkout; nearest-parent lookup covers worktrees
1216
+ removeTrust: (proj) => removePiTrust(mainCheckout(proj)),
1217
+ clean(proj, arts) { cleanHarness(this, proj, arts) },
1218
+ slashCommands: piSlashCommands,
1219
+ // claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
1220
+ // socket the generated extension binds. socketLive is already probed for every windowed session.
1221
+ liveness: (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) => (tmuxAlive && !!socketLive ? 'online' : 'offline'),
1222
+ deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1223
+ // reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
1224
+ // LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
1225
+ resumeArg: (rec) => `--session ${rec.session}`,
1226
+ }
1227
+
1228
+ export const opencodeHarness: Harness = {
1229
+ id: 'opencode',
1230
+ events: OPENCODE_EVENTS,
1231
+ // LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
1232
+ // socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
1233
+ // parse-confirmed write) and socket-listener liveness are reused verbatim — no opencode transport code.
1234
+ ownsRendezvous: true,
1235
+ paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
1236
+ launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
1237
+ baseCmd: opencodeBaseCmd,
1238
+ sessionIdArg: () => '', // opencode mints its own session id; the plugin's first event reports it back (opencode-capture)
1239
+ // opencode exports NO per-session env var to its tool subprocesses (probed, 1.18.3). Identity flows through
1240
+ // the launch-injected SPEXCODE_SESSION_ID — honest here because each opencode TUI is a per-session process
1241
+ // (no codex-style shared-server contamination). This var is therefore never set; envSessionId's
1242
+ // SPEXCODE_SESSION_ID tier resolves the record.
1243
+ sessionEnvVar: 'OPENCODE_SESSION_ID',
1244
+ // the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
1245
+ // by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
1246
+ shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
1247
+ worktreeHookAnchor: () => null,
1248
+ contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
1249
+ skillDir: (proj) => join(proj, '.opencode', 'skills'),
1250
+ agentDir: (proj) => join(proj, '.opencode', 'agents'),
1251
+ // content = the plugin source; cmd = the SAME per-event command the plugin bakes into dispatch calls, so
1252
+ // any consumer that hashes/inspects commands sees one truth (trust is a no-op here regardless).
1253
+ shim: (dispatch, spex) => ({ content: opencodePluginSource(dispatch, spex), cmd: (e) => `SPEX='${spex}' bash ${dispatch} opencode ${e}` }),
1254
+ writeTrust: () => [], // permission policy stays with the launcher command; no trust artifact to report
1255
+ removeTrust: () => { /* nothing was written */ },
1256
+ clean(proj, arts) { cleanHarness(this, proj, arts) },
1257
+ slashCommands: opencodeSlashCommands,
1258
+ // online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
1259
+ // (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
1260
+ // load still reads honestly from the process signal instead of a false offline.
1261
+ liveness: (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
1262
+ (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline'),
1263
+ deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
1264
+ // owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
1265
+ // SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
1266
+ // which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
1267
+ // is: a NEW launch's tail is always ONE single-quoted prompt arg, never a literal marker.
1268
+ resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : '--continue'),
1269
+ }
1270
+
1124
1271
  // every adapter — materialize iterates this to write each harness's artifacts in one pass.
1125
- export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness]
1272
+ export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness]
1126
1273
 
1127
1274
  // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
1128
1275
  export const defaultHarness: Harness = claudeHarness
@@ -1135,16 +1282,17 @@ export function harnessById(id: string): Harness {
1135
1282
  }
1136
1283
 
1137
1284
  // --- named launcher profiles ([[launcher-select]]) ----------------------------------------------------------
1138
- // a launcher = a `{ harness, cmd }` pair in spexcode.json's `sessions.launchers`, keyed by a human-chosen name.
1139
- // `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named launchers (with the
1140
- // regular command path), so they are edited like any other. harness defaults to claude. resolveLauncher throws
1141
- // fail-loud on an unknown name (a session must never silently launch under the wrong auth) and validates the
1142
- // harness id. There is NO env-derived built-in fallback: the dropdown lists exactly the config's real launchers.
1285
+ // a launcher = a `{ harness, cmd }` entry in spexcode.json's `sessions.launchers`, keyed by a
1286
+ // human-chosen name. `claude` and `codex` are NOT special built-ins — `spex init` SEEDS them as ordinary named
1287
+ // launchers (with the regular command path), so they are edited like any other. harness defaults to claude.
1288
+ // resolveLauncher throws fail-loud on an unknown name (a session must never silently launch under the wrong
1289
+ // auth) and validates the harness id. There is NO env-derived built-in fallback: the dropdown lists exactly
1290
+ // the config's real launchers.
1143
1291
  export type Launcher = { name: string; harness: string; cmd: string }
1144
1292
  export type LauncherDefault = { default: string | null; error: string | null }
1145
1293
 
1146
- // the configured named launchers from spexcode.json, as a stable name-sorted list (for the dashboard dropdown +
1147
- // the CLI). Picking a launcher is the ONLY launch choice; the old separate harness pick is gone.
1294
+ // the configured named launchers from spexcode.json, as a stable name-sorted list (for the dashboard dropdown
1295
+ // + the CLI). Picking a launcher is the ONLY launch choice; the old separate harness pick is gone.
1148
1296
  export function launcherList(root = mainCheckout()): Launcher[] {
1149
1297
  const m = readConfig(root).sessions?.launchers || {}
1150
1298
  return Object.keys(m)
@@ -14,7 +14,8 @@
14
14
  type Entry = { line: string; body: string; see?: string }
15
15
 
16
16
  const SEL_NOTE = `SEL = session id (or unique id-prefix) | node id | branch — every session read/control verb
17
- accepts any of the three; on the list verbs (ls/watch), none (or @all) means every session.`
17
+ accepts any of the four; inside a session worktree, . means that worktree's session. On the list verbs (ls/watch),
18
+ none (or @all) means every session.`
18
19
 
19
20
  const ROUTING_NOTE = `Backend routing: every backend-touching verb accepts --api <url> (--port <n> = localhost sugar) to name
20
21
  its backend explicitly — the flag always wins. Bare, it resolves: worker env / the cwd project's live
@@ -24,9 +25,9 @@ const DOT_NOTE = `\`.\` as a node argument means the node THIS worktree works on
24
25
  node/<id> branch). One-shot payload reads (graph · spec search · session ls/show/review · eval ls ·
25
26
  scenario ls · issue ls/show/links) take --json.`
26
27
 
27
- const MENTION_NOTE = `Mentions: @session · [[node]] · @new work in ANY prompt, issue, or remark body — text passed as a CLI
28
- arg included. [[node]] names the topic node; @session hands the text to that live agent; @new spawns a
29
- fresh worker on the thread's node.`
28
+ const MENTION_NOTE = `Mentions: @session · [[node]] · @new / @new:<launcher> work in ANY prompt, issue, or remark body —
29
+ text passed as a CLI arg included. [[node]] names the topic node; @session hands the text to that live agent;
30
+ @new spawns a fresh worker on the thread's node (bare = configured default; :<launcher> = that named profile).`
30
31
 
31
32
  const ENTRIES: Record<string, Entry> = {
32
33
  // ── project verbs (implicit object = this project) ────────────────────────
@@ -44,12 +45,14 @@ derived status, title, and attention badges (drift:N · stale:N · issues:N · g
44
45
  see: 'spex spec search (find one node by intent) · spex session ls (just the sessions, as a table)',
45
46
  },
46
47
  init: {
47
- line: 'init [dir] adopt SpexCode on a repo: seed .spec + hooks + materialize [--preset name]',
48
- body: `Usage: spex init [dir=cwd] [--preset default]
48
+ line: 'init [dir] adopt SpexCode on a repo: seed .spec + hooks + materialize --harness <ids> [--preset name]',
49
+ body: `Usage: spex init [dir=cwd] --harness <id[,id]|plugin:<folder>> [--preset default]
49
50
 
50
51
  Scaffolds adoption in one shot: seeds a starter .spec tree (project root + .plugins plugins), plants
51
52
  spexcode.json, installs the git hooks, and materializes the harness artifacts (contract block +
52
- shims). Additive never overwrites your files. --preset picks the .plugins plugin tier (cumulative).
53
+ shims). --harness is REQUIRED the explicit choice of which harnesses materialize delivers into
54
+ (stamped as spexcode.json "harnesses"; only their launchers are seeded); a pre-existing "harnesses"
55
+ field satisfies it. Additive — never overwrites your files. --preset picks the .plugins plugin tier (cumulative).
53
56
  Footprint is fixed: materialized artifacts are never tracked — hidden via the per-clone .git/info/exclude, with
54
57
  a tracked/mixed CLAUDE.md/AGENTS.md covered by the clean/smudge filter (see spex guide footprint).`,
55
58
  see: 'spex guide (the full setup workflow) · spex uninstall (the inverse) · spex spec lint (adoption TODO)',
@@ -69,24 +72,26 @@ hooks yet (CI, a cloud agent): generated and excluded, the artifacts never arriv
69
72
  see: 'spex doctor (verify the materialized artifacts actually reach an agent)',
70
73
  },
71
74
  doctor: {
72
- line: 'doctor diagnose whether the workflow actually reaches this agent [--contract|--conflicts]',
73
- body: `Usage: spex doctor per-layer coverage report: preconditions · git-hook floor · contract ·
74
- hooks + handler existence · backend for every harness materialize delivers to
75
+ line: 'doctor diagnose spec health and whether the workflow reaches this agent [--contract|--conflicts]',
76
+ body: `Usage: spex doctor spec-health findings + delivery report: preconditions · git-hook floor ·
77
+ contract · hooks + handler existence · backend · footprint
75
78
  spex doctor --contract print the composed surface:system text any agent here reads
76
79
  spex doctor --conflicts detect double-delivery (loose artifacts beside the managed ones)
77
80
 
78
- Run it when a worker seems to be missing its contract or hooks — it names the broken layer and the
79
- repair, instead of you diffing materialized files by hand.`,
80
- see: 'spex materialize (re-materialize the artifacts doctor checks)',
81
+ Bare doctor is the opt-in, read-only health surface: it reports altitude and breadth findings without
82
+ putting them in the lint gate, then audits workflow delivery. Run it directly or let the tidy workflow
83
+ consume the same visible diagnosis.`,
84
+ see: 'spex spec lint (deterministic graph/contract gate) · spex materialize (repair delivery artifacts)',
81
85
  },
82
86
  uninstall: {
83
- line: 'uninstall [dir] surgical inverse of init removes generated artifacts, keeps your .spec [--hooks]',
87
+ line: 'uninstall [dir] remove all derived artifacts + local state; preserve tracked intent [--hooks]',
84
88
  body: `Usage: spex uninstall [dir=cwd] [--hooks]
85
89
 
86
- Removes every SpexCode-GENERATED artifact (harness shims · contract blocks · trust entries ·
87
- exclude/ignore blocks · global store · plugin bundle) and never your .spec/.plugins data or your own
88
- prose. Git hooks are preserved unless --hooks.`,
89
- see: 'spex init (re-adopt later your .spec survives)',
90
+ Removes all SpexCode-derived wiring and project-local state: contract blocks, harness shims,
91
+ generated skills/agents, plugin bundles, trust/filter/exclude entries, and the global per-project
92
+ store. Your tracked intent (.spec including .plugins, plus spexcode.json) and surrounding user prose
93
+ are preserved. Git hooks remain unless --hooks; that flag removes only unmodified canonical copies.`,
94
+ see: 'spex init (re-adopt later — your tracked intent survives)',
90
95
  },
91
96
  serve: {
92
97
  line: 'serve [api|ui] api (default) = the backend :8787 · ui = the dashboard :5173 on top of it',
@@ -106,6 +111,30 @@ projects coexist on one host). Loopback-only by default; --host 0.0.0.0 opens it
106
111
  still plain HTTP with no gate, so bind wide only on a network you trust.`,
107
112
  see: 'GET /health (backend liveness probe)',
108
113
  },
114
+ dashboard: {
115
+ line: 'dashboard ONE dashboard for every project you serve — no --api-port pairing [--port N=5173]',
116
+ body: `Usage: spex dashboard [--port N=5173] [--host H=127.0.0.1]
117
+
118
+ The HOST gateway: serves the built dashboard once and routes to EVERY backend the current user runs —
119
+ the multi-project hub engine plus the host registry on top. It continuously reconciles the per-project
120
+ endpoint records each \`spex serve\` publishes (validating each against the live backend's /api/instance
121
+ identity), keeps a durable known-project catalog, and proxies each project's API + SSE + terminal
122
+ socket under /p/<projectId>/* — the project is named in the path, so nothing is "current" and no
123
+ pairing flag exists.
124
+
125
+ Admin surface (hub-authorized: implicit from loopback until an admin password is set, then cookie
126
+ sessions): GET /projects (the validated list + gating state) · GET /projects/stream (SSE) ·
127
+ GET /projects/browse?path=… (read-only host folder picker) · POST /projects {root, initGit?, init?}
128
+ (explicit setup, then register) · GET|PUT /projects/<id>/config (raw portable
129
+ spexcode.json, revision-guarded) · POST /projects/<id>/init|doctor|serve
130
+ (run the real \`spex init\`/\`spex doctor\`, or start an offline project's backend, detached — a
131
+ backend never depends on this gateway staying up) · PUT|DELETE /projects/admin-password and
132
+ /projects/<id>/password (the gates). A gated project answers /p/<id>/login with the designed page.
133
+
134
+ Loopback-only by default; --host widens the bind — the admin surface stays locked to loopback until
135
+ an admin password exists, and ungated projects serve open.`,
136
+ see: 'spex serve (each project\'s backend) · spex serve ui (explicit one-backend pairing)',
137
+ },
109
138
 
110
139
  // ── the noun drawers ──────────────────────────────────────────────────────
111
140
  spec: {
@@ -131,7 +160,7 @@ commit since the spec's version touched an ANCHORED unit's lines, unacked — th
131
160
  drift; same-file selectors OR'd, one error naming the hit selectors) · one-govern (a node
132
161
  governs >1 DISTINCT file) · living (a "## vN" changelog heading) · id-format (an
133
162
  id char outside the whitelist — ascii [a-z0-9-] or a non-ascii unicode letter/number, CJK ok — or a
134
- leaf id reused) · mention (a [[id]] naming no node). Warns: altitude · breadth · coverage · drift
163
+ leaf id reused) · mention (a [[id]] naming no node). Warns: coverage · drift
135
164
  (UNANCHORED drift — always advisory, never blocks; on a scoped file's MISS, \`lint.scopedCodeMiss:
136
165
  "ignore"\` may silence it) · anchor (anchoring a type) · related-drift (a scoped related row warns
137
166
  per selector HIT, misses silent) · owners (whole-file governors only; scoped don't count) ·
@@ -147,10 +176,10 @@ edit the spec instead — same commit as the code.`,
147
176
  session: {
148
177
  line: 'session <verb> the worktree state machine: new · ls · watch · wait · review · merge · send · …',
149
178
  body: `Manager verbs (dispatch, monitor, land):
150
- spex session new "<prompt>" [--prompt-file <path>|-] [--node <id>] [--launcher <name>]
179
+ spex session new "<prompt>" [--prompt-file <path>|-] [--launcher <name>]
151
180
  Launch a worker in its own node worktree. Give it ONLY its task — the dev-flow contract
152
- reaches it through the materialized system prompt. --node (or the prompt's first [[id]],
153
- same effect) binds the session to that node. --prompt-file <path>|- carries a long prompt
181
+ reaches it through the materialized system prompt. The prompt's first [[id]] mention binds
182
+ the session to that node. --prompt-file <path>|- carries a long prompt
154
183
  without shell quoting (exclusive with the inline prompt). Then MONITOR it (wait/watch below).
155
184
  spex session ls [SEL…] [--status a,b] [--json] one-shot table of living sessions
156
185
  spex session watch [SEL…] [--as NAME] [--idle] [--interval N=5]
@@ -202,15 +231,16 @@ ${MENTION_NOTE}`,
202
231
  see: 'spex eval ls --session <SEL> (the session’s measured loss) · spex help eval',
203
232
  },
204
233
  eval: {
205
- line: 'eval <verb> the measurement system: add · ls · scenario ls · lint · ok · retract · clean',
234
+ line: 'eval <verb> the measurement system: add · ls · scenario ls · matrix · lint · ok · retract · clean',
206
235
  body: `Usage: spex eval add [<node>|.] [--scenario <name>] (--pass|--fail) [--note <text>]
207
236
  [--image <png> …repeatable] [--result <path|->] [--video <webm|mp4>] [--timeline <json>]
208
237
  spex eval ls [<node>|.] [--json] a node's eval timeline, newest first
209
238
  spex eval ls --session <SEL> [--json] a session's aggregate: its changed nodes' scores
210
239
  spex eval ls --session <SEL> --export [--open | --out <path>]
211
240
  spex eval scenario ls [<node>|.] [--unmeasured] [--json] declared scenarios; bare = every node
241
+ spex eval matrix <launcher> [--node <id>] [--rows k1,k2] the harness live-behavior matrix
212
242
  spex eval lint [--changed] measurement-layer findings (advisory, always exit 0)
213
- spex eval ok <node> [--scenario <name>] the HUMAN sign-off on the scenario's latest reading
243
+ spex eval ok <node> [--scenario <name>] the HUMAN sign-off on the scenario's latest measurement
214
244
  spex eval retract [<node>|.] [--scenario <name>] [--last | --ts <iso>] [--note <why>]
215
245
  spex eval clean [--keep-latest | --all] GC the content-addressed evidence cache
216
246
 
@@ -227,16 +257,22 @@ HTML artifact (diff · evidence inlined · gates) for CI/sharing.
227
257
  scenario ls — the DECLARED contracts (name · tags · normalized test reference · latest verdict), no evals: bare lists every
228
258
  measurable node's scenarios; --unmeasured keeps only the never-measured — the blind-spot worklist.
229
259
 
260
+ matrix — run the eight-row harness live-behavior matrix against a REAL dispatched session of the named
261
+ launcher (the harness-adapter acceptance rule, defined once in spec-eval/src/matrix.ts): it syncs the
262
+ rows into the \`<harness>-harness\` node's eval.md scenarios, drives one worker through undeclared-stop ·
263
+ pretooluse-block · ask-note · deliver-steer · resume · liveness · commit-gate · close-residue, and files
264
+ a per-row measurement with its evidence transcript. A new harness needs only its launcher + spec node.
265
+
230
266
  lint — the measurement layer's findings: malformed eval.md (eval-schema) · unmeasured (eval-missing) ·
231
267
  stale (eval-drift) · orphaned remark tracks (eval-dangling) · governed source with no eval.md
232
268
  (eval-coverage — the same name and shape as spec lint's coverage, one rule per layer) · over-owned
233
269
  files (eval-owners). --changed scopes to the nodes THIS branch touched. spec lint's errors block
234
270
  commits; eval lint is PURE ADVISORY, always exit 0 — a measurement gap never blocks anyone.
235
271
 
236
- ok — the human's reviewed-and-agreed mark on the scenario's LATEST reading: an appended, monotonic
237
- sign-off bound to that one immutable reading (a newer reading or staleness releases it on its own —
272
+ ok — the human's reviewed-and-agreed mark on the scenario's LATEST measurement: an appended, monotonic
273
+ sign-off bound to that one immutable measurement (a newer measurement or staleness releases it on its own —
238
274
  no un-ok exists). The evals feed default-hides a fresh, ok'd scenario; a governed session is refused
239
- (an agent's judgment on a reading is a remark, never a self-blessing).
275
+ (an agent's judgment on a measurement is a remark, never a self-blessing).
240
276
 
241
277
  retract — the sanctioned undo for a botched filing: APPENDS a retraction event (traceable, never
242
278
  deletes a line); the previous eval becomes latest again, or the scenario honestly returns to
@@ -363,6 +399,7 @@ Project verbs (implicit object = this project)
363
399
  ${ENTRIES.doctor.line}
364
400
  ${ENTRIES.uninstall.line}
365
401
  ${ENTRIES.serve.line}
402
+ ${ENTRIES.dashboard.line}
366
403
 
367
404
  Noun drawers
368
405
  ${ENTRIES.spec.line}