spexcode 0.2.1 → 0.2.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 (52) hide show
  1. package/README.md +158 -103
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +24 -1
  4. package/spec-cli/src/attach.ts +50 -0
  5. package/spec-cli/src/cli.ts +217 -64
  6. package/spec-cli/src/client.ts +47 -9
  7. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  8. package/spec-cli/src/guide.ts +79 -21
  9. package/spec-cli/src/harness.ts +53 -29
  10. package/spec-cli/src/help.ts +137 -49
  11. package/spec-cli/src/index.ts +31 -11
  12. package/spec-cli/src/issues.ts +48 -21
  13. package/spec-cli/src/layout.ts +3 -5
  14. package/spec-cli/src/lint.ts +34 -5
  15. package/spec-cli/src/localIssues.ts +44 -60
  16. package/spec-cli/src/materialize.ts +4 -2
  17. package/spec-cli/src/mentions.ts +22 -1
  18. package/spec-cli/src/pty-bridge.ts +39 -4
  19. package/spec-cli/src/ranker.ts +31 -12
  20. package/spec-cli/src/search.bench.mjs +30 -7
  21. package/spec-cli/src/search.ts +39 -0
  22. package/spec-cli/src/sessions.ts +160 -69
  23. package/spec-cli/src/specs.ts +16 -4
  24. package/spec-cli/src/supervise.ts +30 -6
  25. package/spec-cli/src/tree.ts +118 -0
  26. package/spec-cli/templates/hooks/post-merge +2 -2
  27. package/spec-cli/templates/hooks/pre-commit +34 -15
  28. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  29. package/spec-cli/templates/spexcode.json +7 -0
  30. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  31. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +27 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-B5leylLf.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-RHNECU6x.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-YLD6IOmC.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-ZnOwskMZ.js +1 -0
  40. package/spec-dashboard/dist/assets/index-BdRQfrkR.js +41 -0
  41. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +1 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-yatsu/src/cli.ts +128 -26
  44. package/spec-yatsu/src/evaltab.ts +7 -6
  45. package/spec-yatsu/src/filing.ts +6 -3
  46. package/spec-yatsu/src/proof.ts +10 -0
  47. package/spec-yatsu/src/scenariofresh.ts +100 -30
  48. package/spec-yatsu/src/sidecar.ts +25 -3
  49. package/spec-yatsu/src/timeline.ts +53 -23
  50. package/README.zh-CN.md +0 -135
  51. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
  52. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
@@ -1,14 +1,29 @@
1
1
  export {} // make this a module so top-level await is allowed
2
+ // static import is fine here: mentions.ts is dependency-free at module level, and stripRefSigil is needed
3
+ // by several verbs (owner, new, tree) — a CLI reference arg tolerates an optional @/[[ ]] sigil ([[mentions]]).
4
+ import { stripRefSigil } from './mentions.js'
5
+
6
+ // @@@ verb mirror - one verb, either drawer ([[cli-surface]]): a session verb promoted to the top
7
+ // level also answers under `spex session …`, and a typeable session sub also answers bare at the top
8
+ // level. Pure argv rewrite BEFORE the single dispatch — an alias, never a second copy of the logic —
9
+ // so every downstream reader (--help interception included) sees the canonical spelling. Hook-driven
10
+ // subs (state · fail · idle · commit-gate) stay namespace-only: nobody types them, so they are not
11
+ // vocabulary to guess.
12
+ const PROMOTED_SESSION_VERBS = new Set(['new', 'ls', 'watch', 'wait', 'review', 'merge'])
13
+ const SESSION_SUBS = new Set(['reopen', 'done', 'park', 'ask', 'exit', 'close', 'send', 'capture', 'attach', 'rename', 'rawkey', 'prompt'])
14
+ if (process.argv[2] === 'session' && PROMOTED_SESSION_VERBS.has(process.argv[3])) process.argv.splice(2, 1)
15
+ else if (SESSION_SUBS.has(process.argv[2])) process.argv.splice(2, 0, 'session')
2
16
  const cmd = process.argv[2]
3
17
 
4
- // Registered before any await so a fatal top-level error lands here. Errors we OWN (BackendError, the
5
- // loud malformed-config ConfigError) are matched BY NAME to avoid importing them and rendered as a
6
- // one-line `spex: <message>` (a user's config typo must read as their typo, not a SpexCode stack dump);
18
+ // Registered before any await so a fatal top-level error lands here. Errors we OWN BackendError, the
19
+ // loud malformed-config ConfigError, the --api/--port UsageError, the write-guard GuardErrorare
20
+ // matched BY NAME (to avoid importing them) and rendered as a one-line `spex: <message>` (a user's
21
+ // config typo or a refused cross-project write must read as their situation, not a SpexCode stack dump);
7
22
  // anything else prints in full so a real bug keeps its trace. A synchronous throw inside an awaited call
8
23
  // (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
9
24
  // paths route through the same printer.
10
25
  function fatal(e: unknown): never {
11
- if (e instanceof Error && (e.name === 'BackendError' || e.name === 'ConfigError')) console.error(`spex: ${e.message}`)
26
+ if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError'].includes(e.name)) console.error(`spex: ${e.message}`)
12
27
  else console.error(e)
13
28
  process.exit(1)
14
29
  }
@@ -37,7 +52,7 @@ function flushExit(code = 0): Promise<never> {
37
52
  }
38
53
  const has = (name: string) => process.argv.includes(`--${name}`)
39
54
  // bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
40
- const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api-port', '--host', '--preset'])
55
+ const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth'])
41
56
  function positionals(from: number): string[] {
42
57
  const out: string[] = []
43
58
  for (let i = from; i < process.argv.length; i++) {
@@ -107,14 +122,37 @@ async function withWatchEdge<T>(selectors: string[], intervalMs: number, body: (
107
122
  async function resolveSelectorOrExit(selector: string): Promise<string> {
108
123
  if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch)'); process.exit(2) }
109
124
  const { resolveClientSession } = await import('./client.js')
125
+ const { sessionLabel } = await import('./sessions.js')
110
126
  const r = await resolveClientSession(selector)
111
127
  if ('ok' in r) return r.ok.id
112
128
  if ('none' in r) { console.error(`spex: no such session: ${selector}`); process.exit(2) }
113
129
  console.error(`spex: ambiguous selector "${selector}" matches ${r.ambiguous.length} sessions — be more specific:`)
114
- for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${s.node || s.branch || s.id}`)
130
+ for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${sessionLabel(s)}`)
115
131
  process.exit(2)
116
132
  }
117
133
 
134
+ // the [[review-proof]] EXPORT artifact, shared by `spex eval <SEL> --export` (canonical) and the deprecated
135
+ // `spex review proof` alias: fetch the backend-rendered self-contained HTML (or the model, --json), write
136
+ // it (--out, else a tmp file) or open it (--open). Never returns.
137
+ async function proofExport(id: string): Promise<never> {
138
+ const { clientProof } = await import('./client.js')
139
+ const r = await clientProof(id, has('json'))
140
+ if (!r.ok) { console.error(`no export for ${id} (status ${r.status})`); process.exit(1) }
141
+ if (has('json')) { console.log(r.body); await flushExit(0) }
142
+ const { writeFileSync } = await import('node:fs')
143
+ const { join } = await import('node:path')
144
+ const { tmpdir } = await import('node:os')
145
+ const out = flag('out') ?? join(tmpdir(), `spexcode-eval-${id.slice(0, 8)}.html`)
146
+ writeFileSync(out, r.body)
147
+ if (has('open')) {
148
+ const { spawn } = await import('node:child_process')
149
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'
150
+ try { spawn(opener, [out], { detached: true, stdio: 'ignore' }).unref(); console.log(`opened ${out}`) }
151
+ catch { console.log(`wrote ${out} — couldn't auto-open, open it in a browser`) }
152
+ } else console.log(out)
153
+ process.exit(0)
154
+ }
155
+
118
156
  // a trailing --help/-h prints help and exits BEFORE any verb runs, so a help probe never fires a
119
157
  // streaming/mutating command. It prints THAT command's usage when an entry exists (the second layer
120
158
  // of the help journey — see help.ts), falling back to the map for an unknown token.
@@ -164,25 +202,36 @@ if (cmd === 'serve') {
164
202
  }
165
203
  console.log(text)
166
204
  } else if (cmd === 'owner') {
167
- const { specOwners } = await import('./specs.js')
205
+ // BOTH [[governed-related]] relations, distinctly: governors (code: — the verdict) and referencers
206
+ // (related: — pointers; coverage only, never drift/yatsu).
207
+ const { specOwners, specRelated } = await import('./specs.js')
168
208
  const { loadConfig } = await import('./lint.js')
169
- const p = positionals(3)[0]
170
- if (!p) { console.error('usage: spex owner <path>'); process.exit(2) }
209
+ const p0 = positionals(3)[0]
210
+ if (!p0) { console.error('usage: spex owner <path> [--actionable]'); process.exit(2) }
211
+ const p = stripRefSigil(p0)
171
212
  const rel = p.startsWith(process.cwd()) ? p.slice(process.cwd().length + 1) : p
172
213
  const owners = specOwners(p)
214
+ const related = specRelated(p)
173
215
  const maxOwners = loadConfig(process.cwd()).maxOwners
174
- if (owners.length === 0) {
175
- console.log(`${rel} no spec governs this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
216
+ const names = (xs: { id: string }[]) => xs.map((o) => `'${o.id}'`).join(', ')
217
+ const relLine = related.length ? `\n also referenced by ${names(related)} (related: coverage only no drift, no yatsu)` : ''
218
+ if (owners.length === 0 && related.length === 0) {
219
+ console.log(`${rel} — no spec claims this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
220
+ } else if (owners.length === 0) {
221
+ // related-only: lint's coverage is satisfied, so the per-edit hook stays silent (lint-consistent) —
222
+ // but a human asking gets the honest nuance: nothing tracks this file's drift.
223
+ if (has('actionable')) process.exit(0)
224
+ console.log(`${rel} — not governed (no code: claim), but referenced by ${names(related)} (related: coverage only). Nothing tracks its drift; if your change is substantive, consider giving it a governing home.`)
176
225
  } else if (owners.length <= maxOwners) {
177
226
  // a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
178
227
  // silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
179
228
  if (has('actionable')) process.exit(0)
180
229
  const named = owners.map((o) => `'${o.id}'`).join(', ')
181
230
  const lead = owners.length === 1 ? `${rel} is governed by ${named} — ${owners[0].desc}` : `${rel} is governed by ${named} (shared, fine).`
182
- console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit.`)
231
+ console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit.${relLine}`)
183
232
  } else {
184
233
  const ids = owners.map((o) => o.id).join(', ')
185
- console.log(`${rel} is governed by ${owners.length} specs (${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).`)
234
+ console.log(`${rel} is governed by ${owners.length} specs (${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
186
235
  }
187
236
  } else if (cmd === 'lint') {
188
237
  const { specLint, driftGate, DRIFT_GUIDANCE } = await import('./lint.js')
@@ -200,7 +249,12 @@ if (cmd === 'serve') {
200
249
  if (blocked.length) console.error(`\n✗ SpexCode: ${blocked.join(', ')} ${blocked.length === 1 ? 'is' : 'are'} ≥ ${threshold} commit(s) behind. Reconcile (above) or bypass with SPEXCODE_SKIP_LINT=1.`)
201
250
  process.exit(errors.length || blocked.length ? 1 : 0)
202
251
  } else if (cmd === 'ack') {
203
- // --amend stamps the Spec-OK trailers in the same block as Session:; git de-dupes adjacent trailers, so re-acking is harmless.
252
+ // An EMPTY stamp commit on top of HEAD, never an amend: driftFor (git.ts) quiets every drift commit
253
+ // REACHABLE from an ack, so a child stamp covers exactly what amending HEAD would — and it works where
254
+ // amend can't: on a trunk merge commit, re-authoring it after MERGE_HEAD is gone reads to main-guard as
255
+ // a direct trunk commit. The guard passes the stamp through its tree-unchanged gate instead. `--only`
256
+ // with no paths pins the commit to HEAD's tree even when the index is dirty — an ack must never sweep
257
+ // staged files along. git de-dupes adjacent trailers, so re-acking is harmless.
204
258
  const { git } = await import('./git.js')
205
259
  const nodes = positionals(3)
206
260
  const reason = (flag('reason') ?? '').trim()
@@ -210,8 +264,9 @@ if (cmd === 'serve') {
210
264
  process.exit(2)
211
265
  }
212
266
  try {
213
- git(['commit', '--amend', '--no-edit', ...nodes.flatMap((n) => ['--trailer', `Spec-OK: ${n}`])])
214
- console.log(`Spec-OK: ${nodes.join(', ')} ${git(['rev-parse', '--short', 'HEAD']).trim()} (reason required, not stored)`)
267
+ git(['commit', '--only', '--allow-empty', '-m', `ack: Spec-OK ${nodes.join(', ')}`,
268
+ ...nodes.flatMap((n) => ['--trailer', `Spec-OK: ${n}`])])
269
+ console.log(`Spec-OK: ${nodes.join(', ')} → ${git(['rev-parse', '--short', 'HEAD']).trim()} (empty stamp commit; reason required, not stored)`)
215
270
  } catch (e: any) {
216
271
  console.error(`ack failed: ${e?.message ?? e}`); process.exit(1)
217
272
  }
@@ -226,26 +281,58 @@ if (cmd === 'serve') {
226
281
  // prose. Git hooks preserved unless --hooks. spex uninstall [targetDir] [--hooks]
227
282
  const { uninstall } = await import('./uninstall.js')
228
283
  uninstall(positionals(3)[0], { hooks: has('hooks') })
284
+ } else if (cmd === 'eval') {
285
+ // the session EVAL read ([[review-proof]]'s interactive face as a CLI verb): the dashboard Eval tab's
286
+ // text twin. Renders the session's changed nodes with each DECLARED scenario at its CURRENT score
287
+ // (latest reading per scenario, worktree-rooted) — blind spots lead, the session's OWN measurements
288
+ // ✦-marked ahead of the inherited baseline under its divider. --export writes the self-contained HTML
289
+ // artifact instead (the demoted `spex review proof`).
290
+ const sel = positionals(3)[0]
291
+ if (!sel) { console.error('usage: spex eval <SEL> [--json] | spex eval <SEL> --export [--open | --out <path> | --json]'); process.exit(2) }
292
+ const id = await resolveSelectorOrExit(sel)
293
+ if (has('export')) await proofExport(id)
294
+ const { clientEvals } = await import('./client.js')
295
+ const r = await clientEvals(id)
296
+ if (!r.ok) { console.error(`no evals for ${id} (status ${r.status})`); process.exit(1) }
297
+ if (has('json')) { console.log(JSON.stringify(r.model, null, 2)); await flushExit(0) }
298
+ const m = r.model
299
+ // mirror the tab's scenarioStates per node: latest reading per DECLARED scenario (evals arrive
300
+ // newest-first), so a retired scenario's residual reading contributes no row; the ✦ count is over
301
+ // these rows — the same number the tab's chip shows.
302
+ const groups = m.nodes.map((n) => {
303
+ const latest = new Map<string, (typeof n.evals)[number]>()
304
+ for (const e of n.evals) if (!latest.has(e.scenario)) latest.set(e.scenario, e)
305
+ const blind = n.scenarios.filter((s) => !latest.has(s.name))
306
+ const rows = n.scenarios.filter((s) => latest.has(s.name)).map((s) => latest.get(s.name)!)
307
+ .sort((a, b) => (Number(b.inSession) - Number(a.inSession)) || (a.ts < b.ts ? 1 : -1))
308
+ return { n, blind, rows }
309
+ })
310
+ const own = groups.reduce((a, g) => a + g.rows.filter((e) => e.inSession).length, 0)
311
+ console.log(`eval ${m.title} [${m.id}]`)
312
+ console.log(` branch : ${m.branch ?? '—'} · ${m.ahead} commit(s) ahead · ${m.dirtyNonRuntime} uncommitted`)
313
+ console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
314
+ if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = inherited baseline)`)
315
+ if (!m.nodes.length) console.log('\n no changed spec nodes — nothing to evaluate yet (empty diff)')
316
+ for (const { n, blind, rows } of groups) {
317
+ console.log(`\n${n.title} [${n.id}]${n.uncoveredFrontend ? ' ⚠ frontend change with NO yatsu.md — a blind spot: give it a scenario' : ''}`)
318
+ for (const s of blind) console.log(` ∅ unmeasured ${s.name} — declared, never measured (blind spot)`)
319
+ let divided = false
320
+ for (const e of rows) {
321
+ if (!e.inSession && !divided && rows.some((x) => x.inSession)) { console.log(` ── inherited baseline (other sessions' latest readings) ──`); divided = true }
322
+ const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
323
+ const stale = e.fresh ? '' : ` (stale: ${e.staleAxes.join(',')})`
324
+ console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
325
+ }
326
+ if (!n.hasYatsu && !n.uncoveredFrontend) console.log(' (no yatsu.md — nothing declared to measure)')
327
+ else if (n.hasYatsu && !n.scenarios.length) console.log(' (yatsu.md declares no scenarios)')
328
+ }
229
329
  } else if (cmd === 'review' && positionals(3)[0] === 'proof') {
330
+ // deprecated alias — proof was demoted from a review sub-noun to the export face of the ONE eval read.
331
+ // Still runs, but echoes the canonical form so callers migrate.
230
332
  const sel = positionals(3)[1]
231
- if (!sel) { console.error('usage: spex review proof <selector> [--open | --out <path> | --json]'); process.exit(2) }
232
- const id = await resolveSelectorOrExit(sel)
233
- const { clientProof } = await import('./client.js')
234
- const r = await clientProof(id, has('json'))
235
- if (!r.ok) { console.error(`no proof for ${id} (status ${r.status})`); process.exit(1) }
236
- if (has('json')) { console.log(r.body); await flushExit(0) }
237
- const { writeFileSync } = await import('node:fs')
238
- const { join } = await import('node:path')
239
- const { tmpdir } = await import('node:os')
240
- const out = flag('out') ?? join(tmpdir(), `spexcode-proof-${id.slice(0, 8)}.html`)
241
- writeFileSync(out, r.body)
242
- if (has('open')) {
243
- const { spawn } = await import('node:child_process')
244
- const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'
245
- try { spawn(opener, [out], { detached: true, stdio: 'ignore' }).unref(); console.log(`opened ${out}`) }
246
- catch { console.log(`wrote ${out} — couldn't auto-open, open it in a browser`) }
247
- } else console.log(out)
248
- process.exit(0)
333
+ if (!sel) { console.error('usage: spex eval <SEL> --export [--open | --out <path> | --json] (`spex review proof` is a deprecated alias)'); process.exit(2) }
334
+ console.error('spex: `spex review proof` is deprecated ≡ spex eval <SEL> --export')
335
+ await proofExport(await resolveSelectorOrExit(sel))
249
336
  } else if (cmd === 'review') {
250
337
  const { clientReview } = await import('./client.js')
251
338
  const sel = positionals(3)[0]
@@ -256,7 +343,7 @@ if (cmd === 'serve') {
256
343
  if (has('json')) { console.log(JSON.stringify(r, null, 2)) }
257
344
  else {
258
345
  const g = r.gates
259
- console.log(`review ${r.node || r.branch || r.id} [${r.id}]`)
346
+ console.log(`review ${r.label} [${r.id}]`)
260
347
  console.log(` ahead of main : ${r.ahead} commit(s)`)
261
348
  console.log(` uncommitted : ${r.dirtyNonRuntime} non-runtime file(s)`)
262
349
  console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
@@ -284,14 +371,16 @@ if (cmd === 'serve') {
284
371
  const { runYatsu } = await import('../../spec-yatsu/src/cli.js')
285
372
  await flushExit(await runYatsu(process.argv.slice(3)))
286
373
  } else if (cmd === 'blob') {
287
- // @@@ blob - the bare evidence-transport verb ([[blob-put]]): put bytes in the shared content-addressed
288
- // cache and print the hash, decoupled from filing a reading. Thin route — the cache lives in spec-yatsu.
374
+ // @@@ blob - the bare evidence-transport pair ([[blob-put]], [[blob-get]]): put bytes in the shared
375
+ // content-addressed cache / read them back by hash, decoupled from filing a reading. Thin route — the
376
+ // cache lives in spec-yatsu. flushExit matters here: `get` pipes raw blob bytes to stdout.
289
377
  const { runBlob } = await import('../../spec-yatsu/src/cli.js')
290
- await flushExit(runBlob(process.argv.slice(3)))
378
+ await flushExit(await runBlob(process.argv.slice(3)))
291
379
  } else if (cmd === 'issues') {
292
380
  // @@@ issues - the ONE issues surface ([[issues]]): bare it is THE read — local + forge issues as ONE
293
- // store-tagged list, the supervisor's/human's drain view; a write first-positional (open|reply|sign|
294
- // resolve|on|off|status, [[local-issues]]) routes to the local store's write verbs, `promote` moves a
381
+ // store-tagged list, the supervisor's/human's drain view; a write first-positional (open|reply|
382
+ // on|off|status, [[local-issues]]) routes to the write verbs (open/reply/close are store-routed —
383
+ // the SAME createIssue/replyIssue/closeIssue the dashboard's API calls), `promote` moves a
295
384
  // thread cross-store. (The pre-rename `spex propose` alias is gone — a deployed post-merge hook still
296
385
  // calling it prints an unknown-command line, advisory-only, until `npm run hooks` reinstalls it.)
297
386
  const { runIssues } = await import('./issues.js')
@@ -309,25 +398,61 @@ if (cmd === 'serve') {
309
398
  // shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
310
399
  const { materialize } = await import('./materialize.js')
311
400
  console.log(`materialized — content-hash ${materialize()}`)
312
- } else if (cmd === 'self') {
313
- // @@@ self - the self-diagnosis surface (spec-cli/self): does the materialized workflow actually reach
314
- // THIS self-launched agent? doctor reports per-layer coverage (preconditions · git-hook floor · contract ·
315
- // hooks+handler-existence · backend) over the same HARNESSES materialize renders through; contract prints
316
- // the surface:system text; env dumps raw facts. Thin route, like forge/yatsu/hooks.
317
- const { runSelf } = await import('./self.js')
318
- await flushExit(await runSelf(process.argv.slice(3)))
401
+ } else if (cmd === 'doctor') {
402
+ // @@@ doctor - the diagnosis surface ([[doctor]], né `self` — renamed: "self" read as the tool itself /
403
+ // the global install, while the report is about THIS agent's wiring): does the materialized workflow
404
+ // actually reach this agent? Bare `doctor` reports per-layer coverage (preconditions · git-hook floor ·
405
+ // contract · hooks+handler-existence · backend) over the same HARNESSES materialize renders through;
406
+ // `contract` prints the surface:system text; `conflicts` just the double-delivery check. Thin route.
407
+ const { runDoctor } = await import('./doctor.js')
408
+ await flushExit(await runDoctor(process.argv.slice(3)))
319
409
  } else if (cmd === 'board') {
320
410
  const { buildBoard } = await import('./board.js')
411
+ // interactive-only stderr hint: the JSON dump is machine food; a human at a tty gets pointed at
412
+ // the readable twin. Piped/redirected stdout stays byte-identical (the hint never touches stdout).
413
+ if (process.stdout.isTTY) console.error('(human-readable tree: spex tree)')
321
414
  console.log(JSON.stringify(await buildBoard(), null, 2))
322
415
  await flushExit(0)
416
+ } else if (cmd === 'tree') {
417
+ // @@@ tree - the human-readable graph ([[spex-tree]]): the same buildBoard() the dashboard renders,
418
+ // as an indented status-coloured terminal tree. Colour degrades cleanly: off unless stdout is a tty,
419
+ // and NO_COLOR always wins.
420
+ const { buildBoard } = await import('./board.js')
421
+ const { renderTree, treeJson } = await import('./tree.js')
422
+ const depthRaw = flag('depth')
423
+ const depth = depthRaw === undefined ? undefined : Number(depthRaw)
424
+ if (depth !== undefined && (!Number.isInteger(depth) || depth < 0)) { console.error('spex tree: --depth must be a non-negative integer'); process.exit(2) }
425
+ const opts = { node: flag('node') && stripRefSigil(flag('node')!), depth, color: process.stdout.isTTY && !process.env.NO_COLOR }
426
+ const { nodes } = await buildBoard()
427
+ try {
428
+ console.log(has('json') ? JSON.stringify(treeJson(nodes, opts), null, 2) : renderTree(nodes, opts))
429
+ } catch (e: any) {
430
+ console.error(`spex tree: ${e?.message ?? e}`)
431
+ process.exit(2)
432
+ }
433
+ await flushExit(0)
323
434
  } else if (cmd === 'search') {
324
- const { searchSpecs } = await import('./search.js')
435
+ const { searchSpecs, nearestTitles } = await import('./search.js')
325
436
  const query = positionals(3).join(' ')
326
437
  if (!query.trim()) { console.error('usage: spex search <query> [--json] [--limit N]'); process.exit(2) }
327
438
  const limit = Number(flag('limit')) || 10
328
439
  const results = await searchSpecs(query, { limit, onStats: (s) => console.error(`[spec-search] compute ${s.ms.toFixed(1)}ms · ${s.nodes} nodes · ${s.tokens} tokens (excludes process start)`) })
329
- if (has('json')) { console.log(JSON.stringify(results)); await flushExit(0) }
330
- if (!results.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
440
+ // zero-result fail-loud + route-to-next-step: always the corpus-is-English fact (unconditional — no
441
+ // language sniffing, no score threshold), plus the nearest titles when anything is even near (typo
442
+ // recovery) and the browse-all pointer, so no query dead-ends.
443
+ const NO_MATCH = (q: string) => {
444
+ const near = nearestTitles(q, 3)
445
+ return [
446
+ `no spec node matches "${q}" (the corpus is English — if your query isn't, translate and retry)`,
447
+ ...(near.length ? ['nearest titles:', ...near.map((t) => ` ${t.title} [${t.id}]`)] : []),
448
+ 'browse all: spex tree',
449
+ ].join('\n')
450
+ }
451
+ if (has('json')) {
452
+ if (!results.length) console.error(NO_MATCH(query)) // stderr: the stdout JSON contract stays verbatim
453
+ console.log(JSON.stringify(results)); await flushExit(0)
454
+ }
455
+ if (!results.length) { console.log(NO_MATCH(query)); process.exit(0) }
331
456
  results.forEach((r, i) => {
332
457
  console.log(`${String(i + 1).padStart(2)}. ${r.title} [${r.id}] · score ${r.score}`)
333
458
  console.log(` ${r.path}`)
@@ -379,8 +504,10 @@ if (cmd === 'serve') {
379
504
  // createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
380
505
  // it falls back to an in-process launch only when no backend answers.
381
506
  const { createSession } = await import('./sessions.js')
507
+ if (has('harness')) { console.error('spex new: --harness was removed; use --launcher <name> (for example --launcher codex)'); process.exit(2) }
382
508
  const prompt = flag('prompt') ?? positionals(3)[0] ?? ''
383
- const created = await createSession(flag('node') ?? null, prompt, flag('harness') ?? undefined, flag('launcher') ?? undefined)
509
+ const nodeArg = flag('node')
510
+ const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
384
511
  console.log(JSON.stringify(created, null, 2))
385
512
  await launchMonitorReminder(created.id)
386
513
  } else if (cmd === 'session') {
@@ -399,14 +526,14 @@ if (cmd === 'serve') {
399
526
  const DECLARED = ' — recorded; the human sees it in the dashboard. This state lives in your session\'s global record; your next tool call flips that record back to active (the mark-active hook, by design), so it is normal for this declaration not to persist.'
400
527
  // appended ONLY to a propose-close declaration: a worktree about to be discarded may still own ephemeral things the agent started to test this change; nudge (not gate) it to reclaim them before the worktree goes, keyed on whether the thing should outlive the task — never on who started it (a deliberately long-running service / a production build is started-by-you yet must be left alone). Project-agnostic on purpose.
401
528
  const CLOSE_CLEANUP = '\n\nBefore this worktree closes, check whether you left anything running that you started to test this change — a background process, a dev or preview server, a bound port, a scratch session. If nothing depends on it anymore, shut it down, or it keeps running as an orphan. Leave anything meant to keep running: a service you deliberately stood up, a production build, anything other work relies on. What matters is whether it still needs to exist after this task, not whether you started it. If unsure, leave it. This is a reminder to check, not a required step.'
402
- if (sub === 'new') {
403
- // route through the backend (auth env + concurrency cap); in-process only if no backend is reachable.
404
- // prompt = --prompt OR the first positional (after `session new`), so `session new "<prompt>"` works the
405
- // SAME as the `spex new "<prompt>"` shorthand one prompt-resolution rule, not two.
406
- const created = await s.createSession(flag('node') ?? null, flag('prompt') ?? positionals(4)[0] ?? '', flag('harness') ?? undefined, flag('launcher') ?? undefined)
407
- console.log(JSON.stringify(created, null, 2))
408
- await launchMonitorReminder(created.id)
409
- } else if (sub === 'reopen') {
529
+ // truncation transparency ([[state]]): the board table shows only the first NOTE_BOARD_LIMIT chars of a
530
+ // note. When a declared note overflows that cap, the confirmation says so length, what the board shows,
531
+ // where the full text is readable — so the cut is visible to the author instead of silently eaten.
532
+ // A nudge riding the echo, never a gate: the declaration has already landed.
533
+ const noteEcho = (note?: string) => (note && note.length > s.NOTE_BOARD_LIMIT)
534
+ ? `\nyour note is ${note.length} chars; the board table shows only the first ${s.NOTE_BOARD_LIMIT} — the full text IS recorded, and readable via spex review ${(sess || s.ownSessionId() || '<your-session>').slice(0, 8)} / spex ls --json.`
535
+ : ''
536
+ if (sub === 'reopen') {
410
537
  // bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
411
538
  // `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts reopen()). The
412
539
  // RESUME GUARD refuses a relaunch on a LIVE/unproven agent (that would kill a live worker) — `--force`
@@ -419,7 +546,7 @@ if (cmd === 'serve') {
419
546
  // the agent authors ITS OWN state: active|awaiting|parked|error [--propose] [--note] [--session]
420
547
  const st = process.argv[4] as any
421
548
  const ok = s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess })
422
- console.log(ok ? `state -> ${st}` : 'no session record (unknown --session / no CLAUDE_CODE_SESSION_ID, or bad status)')
549
+ console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` : 'no session record (unknown --session / no CLAUDE_CODE_SESSION_ID, or bad status)')
423
550
  } else if (sub === 'done') {
424
551
  // sugar for awaiting; --propose merge|nothing|close, optional --note
425
552
  const p = (flag('propose') as any) || 'nothing'
@@ -431,10 +558,10 @@ if (cmd === 'serve') {
431
558
  try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
432
559
  catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
433
560
  }
434
- console.log(s.markDone(p, sess) ? `done (${p})${DECLARED}${closeNote}` : 'no session record')
561
+ console.log(s.markDone(p, sess, flag('note')) ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` : 'no session record')
435
562
  } else if (sub === 'park') {
436
563
  // sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
437
- console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}` : 'no session record')
564
+ console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}${noteEcho(flag('note'))}` : 'no session record')
438
565
  } else if (sub === 'fail') {
439
566
  // the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
440
567
  console.log(s.markError(sess) ? 'marked error' : 'no session record')
@@ -442,7 +569,7 @@ if (cmd === 'serve') {
442
569
  // the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
443
570
  // authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
444
571
  // (waiting on a background task, self-resumes): an asking agent resumes only when the human replies.
445
- console.log(s.markState('asking', { note: flag('note'), sessionId: sess }) ? `asking${DECLARED}` : 'no session record')
572
+ console.log(s.markState('asking', { note: flag('note'), sessionId: sess }) ? `asking${DECLARED}${noteEcho(flag('note'))}` : 'no session record')
446
573
  } else if (sub === 'commit-gate') {
447
574
  // the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
448
575
  // ready to declare done/merge (work committed + ahead of main), else print the reason and exit 1. Uses
@@ -492,6 +619,32 @@ if (cmd === 'serve') {
492
619
  const r = await c.clientCapture(full)
493
620
  if (r.ok) { process.stdout.write(r.pane) }
494
621
  else { console.error(`spex capture: ${r.reason}`); process.exit(r.status === 404 ? 2 : 1) }
622
+ } else if (sub === 'rename') {
623
+ // set the session's display-name override — the right-click rename ([[session-rename]]) as a verb, so an
624
+ // agent manager can fix a label without the GUI. An EXPLICIT "" clears back to the derived label; a
625
+ // MISSING argument is a usage error, never a silent clear. Unknown session → the endpoint's 404, loud.
626
+ const full = await resolveSelectorOrExit(id)
627
+ const name = process.argv[5]
628
+ if (name === undefined) { console.error('usage: spex session rename <SEL> "<name>" (an explicit "" clears the override)'); process.exit(2) }
629
+ if (await c.clientRename(full, name)) console.log(name.trim() ? `${full} -> renamed "${name.trim()}"` : `${full} -> name cleared (derived label restored)`)
630
+ else { console.error(`spex session rename: no such session ${full}`); process.exit(2) }
631
+ } else if (sub === 'rawkey') {
632
+ // forward raw nav-mode keystrokes (tmux send-keys, NEVER the prompt socket) — how a manager drives a
633
+ // worker wedged in an interactive TUI dialog the prompt channel can't reach (a select menu wanting one
634
+ // Enter/arrow). Tokens = named keys, single chars, C-/M-/S- combos; whitespace-separated, delivered as
635
+ // ONE ordered batch ([[nav-mode-key-ordering]]). Fail-loud: nothing delivered exits non-zero.
636
+ const full = await resolveSelectorOrExit(id)
637
+ const keys = process.argv.slice(5).flatMap((s) => s.split(/\s+/)).filter(Boolean)
638
+ if (keys.length === 0) { console.error('usage: spex session rawkey <SEL> "<keys>" (e.g. "Up Up Enter", "C-r", single chars)'); process.exit(2) }
639
+ if (await c.clientRawkey(full, keys)) console.log(`sent ${keys.length} key${keys.length === 1 ? '' : 's'} -> ${full}`)
640
+ else { console.error(`spex session rawkey: nothing delivered to ${full} (offline, unknown session, or no valid key token)`); process.exit(1) }
641
+ } else if (sub === 'attach') {
642
+ // the HUMAN escape hatch (attach.ts, [[session-attach]]): foreground `tmux attach` into the worker's
643
+ // real session. Guards fail loud BEFORE resolving: local-only (the tmux server is the backend
644
+ // machine's) and terminal-only (an agent must never block its turn on it — capture/send/rawkey).
645
+ const { assertLocalBackend, attachSession } = await import('./attach.js')
646
+ await assertLocalBackend()
647
+ await attachSession(await resolveSelectorOrExit(id))
495
648
  } else if (sub === 'prompt') {
496
649
  // print the session's full ORIGINATING prompt (what it was asked to do), captured at launch.
497
650
  const full = await resolveSelectorOrExit(id)
@@ -499,7 +652,7 @@ if (cmd === 'serve') {
499
652
  if (!r.ok) { console.error(`no prompt recorded for ${full}`); process.exit(1) }
500
653
  process.stdout.write(r.prompt.endsWith('\n') ? r.prompt : r.prompt + '\n')
501
654
  } else {
502
- console.error('spex session: new|reopen|done|park|ask|idle|exit|close|send|capture|prompt'); process.exit(2)
655
+ console.error('spex session: new|ls|watch|wait|review|merge|reopen|done|park|ask|exit|close|send|capture|attach|rename|rawkey|prompt (spex help session)'); process.exit(2)
503
656
  }
504
657
  } else if (cmd === 'internal') {
505
658
  // @@@ internal - the machine-plumbing namespace: verbs only generated hooks and launch scripts call,
@@ -1,4 +1,5 @@
1
- import { apiBase, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
1
+ import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
2
+ import type { SessionEvals } from '../../spec-yatsu/src/proof.js'
2
3
 
3
4
  export class BackendError extends Error {
4
5
  constructor(message: string, readonly status?: number) {
@@ -7,15 +8,20 @@ export class BackendError extends Error {
7
8
  }
8
9
  }
9
10
 
10
- // the ONE seam where "no backend" becomes loud. A network failure (nothing listening at apiBase) is the only
11
- // thing thrown; an HTTP Response of any status is returned for the caller to interpret.
11
+ // the ONE seam where "no backend" becomes loud. A network failure (nothing listening at the resolved base)
12
+ // is the only thing thrown; an HTTP Response of any status is returned for the caller to interpret.
12
13
  async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
14
+ const base = await apiBase()
13
15
  try {
14
- return await fetch(`${apiBase()}${path}`, init)
16
+ return await fetch(`${base}${path}`, init)
15
17
  } catch (e) {
16
- throw new BackendError(`no backend reachable at ${apiBase()} — run \`spex serve\` there, or set SPEXCODE_API_URL (${(e as Error).message})`)
18
+ throw new BackendError(`no backend reachable at ${base} — run \`spex serve\` in the project, or name one with --api <url> (${(e as Error).message})`)
17
19
  }
18
20
  }
21
+ // every MUTATING verb is project-bound ([[remote-client]]'s write guard): resolve the backend, compare its
22
+ // served root to the cwd project, refuse loudly on a same-host mismatch — an explicit --api/--port skips it.
23
+ // Reads stay unguarded (viewer-points-anywhere). Guarding HERE (not per cli.ts branch) covers every caller.
24
+ const guarded = (verb: string) => assertProjectMatch(`spex ${verb}`)
19
25
  const post = (body: unknown): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
20
26
  const seg = (id: string) => encodeURIComponent(id)
21
27
 
@@ -43,6 +49,7 @@ export async function clientCapture(id: string): Promise<CaptureResult> {
43
49
  // POST /api/sessions/:id/keys — prompt dispatch (the backend routes it through the rendezvous socket,
44
50
  // socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
45
51
  export async function clientSend(id: string, text: string, from?: string): Promise<DispatchResult> {
52
+ await guarded('session send')
46
53
  // `from` = the sending agent's own session id; the backend logs the comms edge ([[comms-edge]]) only when
47
54
  // it's present (an agent send), so a human-shell send stays unrecorded.
48
55
  const r = await apiFetch(`/api/sessions/${seg(id)}/keys`, post({ text, ...(from ? { from } : {}) }))
@@ -57,10 +64,10 @@ export async function clientReview(id: string): Promise<ReviewPayload | null> {
57
64
  return await r.json() as ReviewPayload
58
65
  }
59
66
 
60
- // GET /api/sessions/:id/proof — the rendered review PROOF ([[review-proof]]): the self-contained HTML the
61
- // backend builds (default), or the model JSON (`json:true` → ?format=json). The engine runs on the backend,
62
- // so the CLI is a thin fetcher that writes/opens these bytes — works against a remote backend unchanged.
63
- // 404 → no such session.
67
+ // GET /api/sessions/:id/proof — the rendered proof EXPORT artifact ([[review-proof]]): the self-contained
68
+ // HTML the backend builds (default), or the model JSON (`json:true` → ?format=json). The engine runs on the
69
+ // backend, so the CLI is a thin fetcher that writes/opens these bytes — works against a remote backend
70
+ // unchanged. 404 → no such session.
64
71
  export type ProofResult = { ok: true; body: string } | { ok: false; status: number }
65
72
  export async function clientProof(id: string, json = false): Promise<ProofResult> {
66
73
  const r = await apiFetch(`/api/sessions/${seg(id)}/proof${json ? '?format=json' : ''}`)
@@ -68,8 +75,19 @@ export async function clientProof(id: string, json = false): Promise<ProofResult
68
75
  return { ok: false, status: r.status }
69
76
  }
70
77
 
78
+ // GET /api/sessions/:id/evals — the session EVAL model ([[review-proof]]'s interactive face): 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 }
82
+ 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 }
86
+ }
87
+
71
88
  // POST /api/sessions/:id/merge — the cockpit's merge DISPATCH (200 {dispatched:true} / 409 {reason}).
72
89
  export async function clientMerge(id: string): Promise<{ dispatched: boolean; reason?: string }> {
90
+ await guarded('merge')
73
91
  const r = await apiFetch(`/api/sessions/${seg(id)}/merge`, post({}))
74
92
  return await r.json().catch(() => ({ dispatched: false, reason: `bad backend response (${r.status})` }))
75
93
  }
@@ -78,6 +96,7 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
78
96
  // working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
79
97
  // `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
80
98
  export async function clientReopen(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
99
+ await guarded('session reopen')
81
100
  const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
82
101
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
83
102
  }
@@ -85,16 +104,35 @@ export async function clientReopen(id: string, force = false): Promise<{ ok: boo
85
104
  // POST /api/sessions/:id/exit — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,
86
105
  // resumable). Distinct from close. {ok:false} = no such session.
87
106
  export async function clientExit(id: string): Promise<boolean> {
107
+ await guarded('session exit')
88
108
  const r = await apiFetch(`/api/sessions/${seg(id)}/exit`, post({}))
89
109
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
90
110
  }
91
111
 
92
112
  // POST /api/sessions/:id/close — the human-only worktree removal. {ok:false} = no such session.
93
113
  export async function clientClose(id: string): Promise<boolean> {
114
+ await guarded('session close')
94
115
  const r = await apiFetch(`/api/sessions/${seg(id)}/close`, post({}))
95
116
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
96
117
  }
97
118
 
119
+ // POST /api/sessions/:id/rename — set (or clear, with a blank) the session's display-name override
120
+ // ([[session-rename]] as a CLI verb). {ok:false} = no such session (404).
121
+ export async function clientRename(id: string, name: string): Promise<boolean> {
122
+ await guarded('session rename')
123
+ const r = await apiFetch(`/api/sessions/${seg(id)}/rename`, post({ name }))
124
+ return !!(await r.json().catch(() => ({ ok: false })))?.ok
125
+ }
126
+
127
+ // POST /api/sessions/:id/rawkey — the raw nav-key channel (tmux send-keys, NEVER the prompt socket): an
128
+ // ordered token batch drives an interactive TUI menu ([[nav-mode-key-ordering]]). {ok:false} = unknown
129
+ // session, no live pane, or no valid token delivered.
130
+ export async function clientRawkey(id: string, keys: string[]): Promise<boolean> {
131
+ await guarded('session rawkey')
132
+ const r = await apiFetch(`/api/sessions/${seg(id)}/rawkey`, post({ keys }))
133
+ return !!(await r.json().catch(() => ({ ok: false })))?.ok
134
+ }
135
+
98
136
  // GET /api/sessions/:id/prompt — the session's originating prompt (404 if none recorded).
99
137
  export type PromptResult = { ok: true; prompt: string } | { ok: false; status: number }
100
138
  export async function clientPrompt(id: string): Promise<PromptResult> {