spexcode 0.2.0 → 0.2.2
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.
- package/README.md +149 -102
- package/README.zh-CN.md +170 -0
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +24 -1
- package/spec-cli/src/attach.ts +50 -0
- package/spec-cli/src/cli.ts +227 -66
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/gateway.ts +15 -11
- package/spec-cli/src/guide.ts +73 -17
- package/spec-cli/src/harness.ts +48 -19
- package/spec-cli/src/help.ts +141 -51
- package/spec-cli/src/index.ts +41 -14
- package/spec-cli/src/issues.ts +109 -31
- package/spec-cli/src/layout.ts +4 -4
- package/spec-cli/src/localIssues.ts +59 -58
- package/spec-cli/src/materialize.ts +4 -2
- package/spec-cli/src/mentions.ts +22 -1
- package/spec-cli/src/pty-bridge.ts +39 -4
- package/spec-cli/src/ranker.ts +31 -12
- package/spec-cli/src/search.bench.mjs +30 -7
- package/spec-cli/src/search.ts +39 -0
- package/spec-cli/src/sessions.ts +149 -62
- package/spec-cli/src/specs.ts +16 -4
- package/spec-cli/src/supervise.ts +30 -6
- package/spec-cli/src/tree.ts +118 -0
- package/spec-cli/templates/hooks/post-merge +2 -2
- package/spec-cli/templates/hooks/pre-commit +34 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
- package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
- package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
- package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
- package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-yatsu/src/cli.ts +89 -15
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +0 -32
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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
|
|
5
|
-
// loud malformed-config ConfigError
|
|
6
|
-
// one-line `spex: <message>` (a user's
|
|
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 GuardError — are
|
|
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 &&
|
|
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', '--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++) {
|
|
@@ -115,6 +130,28 @@ async function resolveSelectorOrExit(selector: string): Promise<string> {
|
|
|
115
130
|
process.exit(2)
|
|
116
131
|
}
|
|
117
132
|
|
|
133
|
+
// the [[review-proof]] EXPORT artifact, shared by `spex eval <SEL> --export` (canonical) and the deprecated
|
|
134
|
+
// `spex review proof` alias: fetch the backend-rendered self-contained HTML (or the model, --json), write
|
|
135
|
+
// it (--out, else a tmp file) or open it (--open). Never returns.
|
|
136
|
+
async function proofExport(id: string): Promise<never> {
|
|
137
|
+
const { clientProof } = await import('./client.js')
|
|
138
|
+
const r = await clientProof(id, has('json'))
|
|
139
|
+
if (!r.ok) { console.error(`no export for ${id} (status ${r.status})`); process.exit(1) }
|
|
140
|
+
if (has('json')) { console.log(r.body); await flushExit(0) }
|
|
141
|
+
const { writeFileSync } = await import('node:fs')
|
|
142
|
+
const { join } = await import('node:path')
|
|
143
|
+
const { tmpdir } = await import('node:os')
|
|
144
|
+
const out = flag('out') ?? join(tmpdir(), `spexcode-eval-${id.slice(0, 8)}.html`)
|
|
145
|
+
writeFileSync(out, r.body)
|
|
146
|
+
if (has('open')) {
|
|
147
|
+
const { spawn } = await import('node:child_process')
|
|
148
|
+
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
149
|
+
try { spawn(opener, [out], { detached: true, stdio: 'ignore' }).unref(); console.log(`opened ${out}`) }
|
|
150
|
+
catch { console.log(`wrote ${out} — couldn't auto-open, open it in a browser`) }
|
|
151
|
+
} else console.log(out)
|
|
152
|
+
process.exit(0)
|
|
153
|
+
}
|
|
154
|
+
|
|
118
155
|
// a trailing --help/-h prints help and exits BEFORE any verb runs, so a help probe never fires a
|
|
119
156
|
// streaming/mutating command. It prints THAT command's usage when an entry exists (the second layer
|
|
120
157
|
// of the help journey — see help.ts), falling back to the map for an unknown token.
|
|
@@ -137,13 +174,15 @@ if (cmd === 'serve') {
|
|
|
137
174
|
}
|
|
138
175
|
await import('./supervise.js')
|
|
139
176
|
} else if (cmd === 'dashboard') {
|
|
140
|
-
// the natural post-install UI: serve the bundled dashboard on its OWN
|
|
141
|
-
// the
|
|
177
|
+
// the natural post-install UI: serve the bundled dashboard on its OWN port (loopback by default;
|
|
178
|
+
// --host widens the bind for LAN/tailnet viewing), proxying /api + the terminal socket to a
|
|
179
|
+
// separately-run `spex serve`. Replaces the dogfood-only `npm run web` (vite).
|
|
142
180
|
const { serveDashboardLocal } = await import('./gateway.js')
|
|
143
181
|
const port = Number(flag('port') ?? process.env.SPEXCODE_DASHBOARD_PORT ?? 5173)
|
|
144
182
|
const apiPort = Number(flag('api-port') ?? process.env.PORT ?? 8787)
|
|
183
|
+
const host = flag('host') ?? '127.0.0.1'
|
|
145
184
|
if (!Number.isInteger(port) || !Number.isInteger(apiPort)) { console.error('spex dashboard: --port and --api-port must be integers'); process.exit(2) }
|
|
146
|
-
serveDashboardLocal({ port, apiPort })
|
|
185
|
+
serveDashboardLocal({ port, apiPort, host })
|
|
147
186
|
} else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
148
187
|
// `spex help <cmd>` drills into one command; bare help is the map. Both name the next layer down.
|
|
149
188
|
const { commandHelp, overviewHelp } = await import('./help.js')
|
|
@@ -162,25 +201,36 @@ if (cmd === 'serve') {
|
|
|
162
201
|
}
|
|
163
202
|
console.log(text)
|
|
164
203
|
} else if (cmd === 'owner') {
|
|
165
|
-
|
|
204
|
+
// BOTH [[governed-related]] relations, distinctly: governors (code: — the verdict) and referencers
|
|
205
|
+
// (related: — pointers; coverage only, never drift/yatsu).
|
|
206
|
+
const { specOwners, specRelated } = await import('./specs.js')
|
|
166
207
|
const { loadConfig } = await import('./lint.js')
|
|
167
|
-
const
|
|
168
|
-
if (!
|
|
208
|
+
const p0 = positionals(3)[0]
|
|
209
|
+
if (!p0) { console.error('usage: spex owner <path> [--actionable]'); process.exit(2) }
|
|
210
|
+
const p = stripRefSigil(p0)
|
|
169
211
|
const rel = p.startsWith(process.cwd()) ? p.slice(process.cwd().length + 1) : p
|
|
170
212
|
const owners = specOwners(p)
|
|
213
|
+
const related = specRelated(p)
|
|
171
214
|
const maxOwners = loadConfig(process.cwd()).maxOwners
|
|
172
|
-
|
|
173
|
-
|
|
215
|
+
const names = (xs: { id: string }[]) => xs.map((o) => `'${o.id}'`).join(', ')
|
|
216
|
+
const relLine = related.length ? `\n also referenced by ${names(related)} (related: coverage only — no drift, no yatsu)` : ''
|
|
217
|
+
if (owners.length === 0 && related.length === 0) {
|
|
218
|
+
console.log(`${rel} — no spec claims this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
|
|
219
|
+
} else if (owners.length === 0) {
|
|
220
|
+
// related-only: lint's coverage is satisfied, so the per-edit hook stays silent (lint-consistent) —
|
|
221
|
+
// but a human asking gets the honest nuance: nothing tracks this file's drift.
|
|
222
|
+
if (has('actionable')) process.exit(0)
|
|
223
|
+
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.`)
|
|
174
224
|
} else if (owners.length <= maxOwners) {
|
|
175
225
|
// a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
|
|
176
226
|
// silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
|
|
177
227
|
if (has('actionable')) process.exit(0)
|
|
178
228
|
const named = owners.map((o) => `'${o.id}'`).join(', ')
|
|
179
229
|
const lead = owners.length === 1 ? `${rel} is governed by ${named} — ${owners[0].desc}` : `${rel} is governed by ${named} (shared, fine).`
|
|
180
|
-
console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit
|
|
230
|
+
console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit.${relLine}`)
|
|
181
231
|
} else {
|
|
182
232
|
const ids = owners.map((o) => o.id).join(', ')
|
|
183
|
-
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)
|
|
233
|
+
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}`)
|
|
184
234
|
}
|
|
185
235
|
} else if (cmd === 'lint') {
|
|
186
236
|
const { specLint, driftGate, DRIFT_GUIDANCE } = await import('./lint.js')
|
|
@@ -198,7 +248,12 @@ if (cmd === 'serve') {
|
|
|
198
248
|
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.`)
|
|
199
249
|
process.exit(errors.length || blocked.length ? 1 : 0)
|
|
200
250
|
} else if (cmd === 'ack') {
|
|
201
|
-
//
|
|
251
|
+
// An EMPTY stamp commit on top of HEAD, never an amend: driftFor (git.ts) quiets every drift commit
|
|
252
|
+
// REACHABLE from an ack, so a child stamp covers exactly what amending HEAD would — and it works where
|
|
253
|
+
// amend can't: on a trunk merge commit, re-authoring it after MERGE_HEAD is gone reads to main-guard as
|
|
254
|
+
// a direct trunk commit. The guard passes the stamp through its tree-unchanged gate instead. `--only`
|
|
255
|
+
// with no paths pins the commit to HEAD's tree even when the index is dirty — an ack must never sweep
|
|
256
|
+
// staged files along. git de-dupes adjacent trailers, so re-acking is harmless.
|
|
202
257
|
const { git } = await import('./git.js')
|
|
203
258
|
const nodes = positionals(3)
|
|
204
259
|
const reason = (flag('reason') ?? '').trim()
|
|
@@ -208,8 +263,9 @@ if (cmd === 'serve') {
|
|
|
208
263
|
process.exit(2)
|
|
209
264
|
}
|
|
210
265
|
try {
|
|
211
|
-
git(['commit', '--
|
|
212
|
-
|
|
266
|
+
git(['commit', '--only', '--allow-empty', '-m', `ack: Spec-OK ${nodes.join(', ')}`,
|
|
267
|
+
...nodes.flatMap((n) => ['--trailer', `Spec-OK: ${n}`])])
|
|
268
|
+
console.log(`Spec-OK: ${nodes.join(', ')} → ${git(['rev-parse', '--short', 'HEAD']).trim()} (empty stamp commit; reason required, not stored)`)
|
|
213
269
|
} catch (e: any) {
|
|
214
270
|
console.error(`ack failed: ${e?.message ?? e}`); process.exit(1)
|
|
215
271
|
}
|
|
@@ -224,26 +280,58 @@ if (cmd === 'serve') {
|
|
|
224
280
|
// prose. Git hooks preserved unless --hooks. spex uninstall [targetDir] [--hooks]
|
|
225
281
|
const { uninstall } = await import('./uninstall.js')
|
|
226
282
|
uninstall(positionals(3)[0], { hooks: has('hooks') })
|
|
283
|
+
} else if (cmd === 'eval') {
|
|
284
|
+
// the session EVAL read ([[review-proof]]'s interactive face as a CLI verb): the dashboard Eval tab's
|
|
285
|
+
// text twin. Renders the session's changed nodes with each DECLARED scenario at its CURRENT score
|
|
286
|
+
// (latest reading per scenario, worktree-rooted) — blind spots lead, the session's OWN measurements
|
|
287
|
+
// ✦-marked ahead of the inherited baseline under its divider. --export writes the self-contained HTML
|
|
288
|
+
// artifact instead (the demoted `spex review proof`).
|
|
289
|
+
const sel = positionals(3)[0]
|
|
290
|
+
if (!sel) { console.error('usage: spex eval <SEL> [--json] | spex eval <SEL> --export [--open | --out <path> | --json]'); process.exit(2) }
|
|
291
|
+
const id = await resolveSelectorOrExit(sel)
|
|
292
|
+
if (has('export')) await proofExport(id)
|
|
293
|
+
const { clientEvals } = await import('./client.js')
|
|
294
|
+
const r = await clientEvals(id)
|
|
295
|
+
if (!r.ok) { console.error(`no evals for ${id} (status ${r.status})`); process.exit(1) }
|
|
296
|
+
if (has('json')) { console.log(JSON.stringify(r.model, null, 2)); await flushExit(0) }
|
|
297
|
+
const m = r.model
|
|
298
|
+
// mirror the tab's scenarioStates per node: latest reading per DECLARED scenario (evals arrive
|
|
299
|
+
// newest-first), so a retired scenario's residual reading contributes no row; the ✦ count is over
|
|
300
|
+
// these rows — the same number the tab's chip shows.
|
|
301
|
+
const groups = m.nodes.map((n) => {
|
|
302
|
+
const latest = new Map<string, (typeof n.evals)[number]>()
|
|
303
|
+
for (const e of n.evals) if (!latest.has(e.scenario)) latest.set(e.scenario, e)
|
|
304
|
+
const blind = n.scenarios.filter((s) => !latest.has(s.name))
|
|
305
|
+
const rows = n.scenarios.filter((s) => latest.has(s.name)).map((s) => latest.get(s.name)!)
|
|
306
|
+
.sort((a, b) => (Number(b.inSession) - Number(a.inSession)) || (a.ts < b.ts ? 1 : -1))
|
|
307
|
+
return { n, blind, rows }
|
|
308
|
+
})
|
|
309
|
+
const own = groups.reduce((a, g) => a + g.rows.filter((e) => e.inSession).length, 0)
|
|
310
|
+
console.log(`eval ${m.title} [${m.id}]`)
|
|
311
|
+
console.log(` branch : ${m.branch ?? '—'} · ${m.ahead} commit(s) ahead · ${m.dirtyNonRuntime} uncommitted`)
|
|
312
|
+
console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
|
|
313
|
+
if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = inherited baseline)`)
|
|
314
|
+
if (!m.nodes.length) console.log('\n no changed spec nodes — nothing to evaluate yet (empty diff)')
|
|
315
|
+
for (const { n, blind, rows } of groups) {
|
|
316
|
+
console.log(`\n${n.title} [${n.id}]${n.uncoveredFrontend ? ' ⚠ frontend change with NO yatsu.md — a blind spot: give it a scenario' : ''}`)
|
|
317
|
+
for (const s of blind) console.log(` ∅ unmeasured ${s.name} — declared, never measured (blind spot)`)
|
|
318
|
+
let divided = false
|
|
319
|
+
for (const e of rows) {
|
|
320
|
+
if (!e.inSession && !divided && rows.some((x) => x.inSession)) { console.log(` ── inherited baseline (other sessions' latest readings) ──`); divided = true }
|
|
321
|
+
const verdict = e.verdict?.status === 'pass' ? '✓ pass' : e.verdict?.status === 'fail' ? '✗ fail' : '· unscored'
|
|
322
|
+
const stale = e.fresh ? '' : ` (stale: ${e.staleAxes.join(',')})`
|
|
323
|
+
console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
|
|
324
|
+
}
|
|
325
|
+
if (!n.hasYatsu && !n.uncoveredFrontend) console.log(' (no yatsu.md — nothing declared to measure)')
|
|
326
|
+
else if (n.hasYatsu && !n.scenarios.length) console.log(' (yatsu.md declares no scenarios)')
|
|
327
|
+
}
|
|
227
328
|
} else if (cmd === 'review' && positionals(3)[0] === 'proof') {
|
|
329
|
+
// deprecated alias — proof was demoted from a review sub-noun to the export face of the ONE eval read.
|
|
330
|
+
// Still runs, but echoes the canonical form so callers migrate.
|
|
228
331
|
const sel = positionals(3)[1]
|
|
229
|
-
if (!sel) { console.error('usage: spex
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const r = await clientProof(id, has('json'))
|
|
233
|
-
if (!r.ok) { console.error(`no proof for ${id} (status ${r.status})`); process.exit(1) }
|
|
234
|
-
if (has('json')) { console.log(r.body); await flushExit(0) }
|
|
235
|
-
const { writeFileSync } = await import('node:fs')
|
|
236
|
-
const { join } = await import('node:path')
|
|
237
|
-
const { tmpdir } = await import('node:os')
|
|
238
|
-
const out = flag('out') ?? join(tmpdir(), `spexcode-proof-${id.slice(0, 8)}.html`)
|
|
239
|
-
writeFileSync(out, r.body)
|
|
240
|
-
if (has('open')) {
|
|
241
|
-
const { spawn } = await import('node:child_process')
|
|
242
|
-
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
243
|
-
try { spawn(opener, [out], { detached: true, stdio: 'ignore' }).unref(); console.log(`opened ${out}`) }
|
|
244
|
-
catch { console.log(`wrote ${out} — couldn't auto-open, open it in a browser`) }
|
|
245
|
-
} else console.log(out)
|
|
246
|
-
process.exit(0)
|
|
332
|
+
if (!sel) { console.error('usage: spex eval <SEL> --export [--open | --out <path> | --json] (`spex review proof` is a deprecated alias)'); process.exit(2) }
|
|
333
|
+
console.error('spex: `spex review proof` is deprecated ≡ spex eval <SEL> --export')
|
|
334
|
+
await proofExport(await resolveSelectorOrExit(sel))
|
|
247
335
|
} else if (cmd === 'review') {
|
|
248
336
|
const { clientReview } = await import('./client.js')
|
|
249
337
|
const sel = positionals(3)[0]
|
|
@@ -282,14 +370,16 @@ if (cmd === 'serve') {
|
|
|
282
370
|
const { runYatsu } = await import('../../spec-yatsu/src/cli.js')
|
|
283
371
|
await flushExit(await runYatsu(process.argv.slice(3)))
|
|
284
372
|
} else if (cmd === 'blob') {
|
|
285
|
-
// @@@ blob - the bare evidence-transport
|
|
286
|
-
// cache
|
|
373
|
+
// @@@ blob - the bare evidence-transport pair ([[blob-put]], [[blob-get]]): put bytes in the shared
|
|
374
|
+
// content-addressed cache / read them back by hash, decoupled from filing a reading. Thin route — the
|
|
375
|
+
// cache lives in spec-yatsu. flushExit matters here: `get` pipes raw blob bytes to stdout.
|
|
287
376
|
const { runBlob } = await import('../../spec-yatsu/src/cli.js')
|
|
288
|
-
await flushExit(runBlob(process.argv.slice(3)))
|
|
377
|
+
await flushExit(await runBlob(process.argv.slice(3)))
|
|
289
378
|
} else if (cmd === 'issues') {
|
|
290
379
|
// @@@ issues - the ONE issues surface ([[issues]]): bare it is THE read — local + forge issues as ONE
|
|
291
|
-
// store-tagged list, the supervisor's/human's drain view; a write first-positional (open|reply|
|
|
292
|
-
//
|
|
380
|
+
// store-tagged list, the supervisor's/human's drain view; a write first-positional (open|reply|
|
|
381
|
+
// on|off|status, [[local-issues]]) routes to the write verbs (open/reply/close are store-routed —
|
|
382
|
+
// the SAME createIssue/replyIssue/closeIssue the dashboard's API calls), `promote` moves a
|
|
293
383
|
// thread cross-store. (The pre-rename `spex propose` alias is gone — a deployed post-merge hook still
|
|
294
384
|
// calling it prints an unknown-command line, advisory-only, until `npm run hooks` reinstalls it.)
|
|
295
385
|
const { runIssues } = await import('./issues.js')
|
|
@@ -307,25 +397,61 @@ if (cmd === 'serve') {
|
|
|
307
397
|
// shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
|
|
308
398
|
const { materialize } = await import('./materialize.js')
|
|
309
399
|
console.log(`materialized — content-hash ${materialize()}`)
|
|
310
|
-
} else if (cmd === '
|
|
311
|
-
// @@@
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
400
|
+
} else if (cmd === 'doctor') {
|
|
401
|
+
// @@@ doctor - the diagnosis surface ([[doctor]], né `self` — renamed: "self" read as the tool itself /
|
|
402
|
+
// the global install, while the report is about THIS agent's wiring): does the materialized workflow
|
|
403
|
+
// actually reach this agent? Bare `doctor` reports per-layer coverage (preconditions · git-hook floor ·
|
|
404
|
+
// contract · hooks+handler-existence · backend) over the same HARNESSES materialize renders through;
|
|
405
|
+
// `contract` prints the surface:system text; `conflicts` just the double-delivery check. Thin route.
|
|
406
|
+
const { runDoctor } = await import('./doctor.js')
|
|
407
|
+
await flushExit(await runDoctor(process.argv.slice(3)))
|
|
317
408
|
} else if (cmd === 'board') {
|
|
318
409
|
const { buildBoard } = await import('./board.js')
|
|
410
|
+
// interactive-only stderr hint: the JSON dump is machine food; a human at a tty gets pointed at
|
|
411
|
+
// the readable twin. Piped/redirected stdout stays byte-identical (the hint never touches stdout).
|
|
412
|
+
if (process.stdout.isTTY) console.error('(human-readable tree: spex tree)')
|
|
319
413
|
console.log(JSON.stringify(await buildBoard(), null, 2))
|
|
320
414
|
await flushExit(0)
|
|
415
|
+
} else if (cmd === 'tree') {
|
|
416
|
+
// @@@ tree - the human-readable graph ([[spex-tree]]): the same buildBoard() the dashboard renders,
|
|
417
|
+
// as an indented status-coloured terminal tree. Colour degrades cleanly: off unless stdout is a tty,
|
|
418
|
+
// and NO_COLOR always wins.
|
|
419
|
+
const { buildBoard } = await import('./board.js')
|
|
420
|
+
const { renderTree, treeJson } = await import('./tree.js')
|
|
421
|
+
const depthRaw = flag('depth')
|
|
422
|
+
const depth = depthRaw === undefined ? undefined : Number(depthRaw)
|
|
423
|
+
if (depth !== undefined && (!Number.isInteger(depth) || depth < 0)) { console.error('spex tree: --depth must be a non-negative integer'); process.exit(2) }
|
|
424
|
+
const opts = { node: flag('node') && stripRefSigil(flag('node')!), depth, color: process.stdout.isTTY && !process.env.NO_COLOR }
|
|
425
|
+
const { nodes } = await buildBoard()
|
|
426
|
+
try {
|
|
427
|
+
console.log(has('json') ? JSON.stringify(treeJson(nodes, opts), null, 2) : renderTree(nodes, opts))
|
|
428
|
+
} catch (e: any) {
|
|
429
|
+
console.error(`spex tree: ${e?.message ?? e}`)
|
|
430
|
+
process.exit(2)
|
|
431
|
+
}
|
|
432
|
+
await flushExit(0)
|
|
321
433
|
} else if (cmd === 'search') {
|
|
322
|
-
const { searchSpecs } = await import('./search.js')
|
|
434
|
+
const { searchSpecs, nearestTitles } = await import('./search.js')
|
|
323
435
|
const query = positionals(3).join(' ')
|
|
324
436
|
if (!query.trim()) { console.error('usage: spex search <query> [--json] [--limit N]'); process.exit(2) }
|
|
325
437
|
const limit = Number(flag('limit')) || 10
|
|
326
438
|
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)`) })
|
|
327
|
-
|
|
328
|
-
|
|
439
|
+
// zero-result fail-loud + route-to-next-step: always the corpus-is-English fact (unconditional — no
|
|
440
|
+
// language sniffing, no score threshold), plus the nearest titles when anything is even near (typo
|
|
441
|
+
// recovery) and the browse-all pointer, so no query dead-ends.
|
|
442
|
+
const NO_MATCH = (q: string) => {
|
|
443
|
+
const near = nearestTitles(q, 3)
|
|
444
|
+
return [
|
|
445
|
+
`no spec node matches "${q}" (the corpus is English — if your query isn't, translate and retry)`,
|
|
446
|
+
...(near.length ? ['nearest titles:', ...near.map((t) => ` ${t.title} [${t.id}]`)] : []),
|
|
447
|
+
'browse all: spex tree',
|
|
448
|
+
].join('\n')
|
|
449
|
+
}
|
|
450
|
+
if (has('json')) {
|
|
451
|
+
if (!results.length) console.error(NO_MATCH(query)) // stderr: the stdout JSON contract stays verbatim
|
|
452
|
+
console.log(JSON.stringify(results)); await flushExit(0)
|
|
453
|
+
}
|
|
454
|
+
if (!results.length) { console.log(NO_MATCH(query)); process.exit(0) }
|
|
329
455
|
results.forEach((r, i) => {
|
|
330
456
|
console.log(`${String(i + 1).padStart(2)}. ${r.title} [${r.id}] · score ${r.score}`)
|
|
331
457
|
console.log(` ${r.path}`)
|
|
@@ -377,8 +503,10 @@ if (cmd === 'serve') {
|
|
|
377
503
|
// createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
|
|
378
504
|
// it falls back to an in-process launch only when no backend answers.
|
|
379
505
|
const { createSession } = await import('./sessions.js')
|
|
506
|
+
if (has('harness')) { console.error('spex new: --harness was removed; use --launcher <name> (for example --launcher codex)'); process.exit(2) }
|
|
380
507
|
const prompt = flag('prompt') ?? positionals(3)[0] ?? ''
|
|
381
|
-
const
|
|
508
|
+
const nodeArg = flag('node')
|
|
509
|
+
const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
|
|
382
510
|
console.log(JSON.stringify(created, null, 2))
|
|
383
511
|
await launchMonitorReminder(created.id)
|
|
384
512
|
} else if (cmd === 'session') {
|
|
@@ -397,14 +525,14 @@ if (cmd === 'serve') {
|
|
|
397
525
|
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.'
|
|
398
526
|
// 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.
|
|
399
527
|
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.'
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
528
|
+
// truncation transparency ([[state]]): the board table shows only the first NOTE_BOARD_LIMIT chars of a
|
|
529
|
+
// note. When a declared note overflows that cap, the confirmation says so — length, what the board shows,
|
|
530
|
+
// where the full text is readable — so the cut is visible to the author instead of silently eaten.
|
|
531
|
+
// A nudge riding the echo, never a gate: the declaration has already landed.
|
|
532
|
+
const noteEcho = (note?: string) => (note && note.length > s.NOTE_BOARD_LIMIT)
|
|
533
|
+
? `\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.`
|
|
534
|
+
: ''
|
|
535
|
+
if (sub === 'reopen') {
|
|
408
536
|
// bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
|
|
409
537
|
// `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts reopen()). The
|
|
410
538
|
// RESUME GUARD refuses a relaunch on a LIVE/unproven agent (that would kill a live worker) — `--force`
|
|
@@ -417,15 +545,22 @@ if (cmd === 'serve') {
|
|
|
417
545
|
// the agent authors ITS OWN state: active|awaiting|parked|error [--propose] [--note] [--session]
|
|
418
546
|
const st = process.argv[4] as any
|
|
419
547
|
const ok = s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess })
|
|
420
|
-
console.log(ok ? `state -> ${st}` : 'no session record (unknown --session / no CLAUDE_CODE_SESSION_ID, or bad status)')
|
|
548
|
+
console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` : 'no session record (unknown --session / no CLAUDE_CODE_SESSION_ID, or bad status)')
|
|
421
549
|
} else if (sub === 'done') {
|
|
422
550
|
// sugar for awaiting; --propose merge|nothing|close, optional --note
|
|
423
551
|
const p = (flag('propose') as any) || 'nothing'
|
|
424
|
-
|
|
425
|
-
|
|
552
|
+
let closeNote = p === 'close' ? CLOSE_CLEANUP : ''
|
|
553
|
+
if (p === 'close') {
|
|
554
|
+
// the DATA half of the close nudge ([[local-issues]] closeoutNudge): the still-open local issues this
|
|
555
|
+
// session touched, listed by id — empty/OFF/no-identity prints nothing. Loud on failure but never
|
|
556
|
+
// gating: the declaration must land whatever the issue store is doing.
|
|
557
|
+
try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
|
|
558
|
+
catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
|
|
559
|
+
}
|
|
560
|
+
console.log(s.markDone(p, sess, flag('note')) ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` : 'no session record')
|
|
426
561
|
} else if (sub === 'park') {
|
|
427
562
|
// sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
|
|
428
|
-
console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}` : 'no session record')
|
|
563
|
+
console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}${noteEcho(flag('note'))}` : 'no session record')
|
|
429
564
|
} else if (sub === 'fail') {
|
|
430
565
|
// the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
|
|
431
566
|
console.log(s.markError(sess) ? 'marked error' : 'no session record')
|
|
@@ -433,7 +568,7 @@ if (cmd === 'serve') {
|
|
|
433
568
|
// the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
|
|
434
569
|
// authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
|
|
435
570
|
// (waiting on a background task, self-resumes): an asking agent resumes only when the human replies.
|
|
436
|
-
console.log(s.markState('asking', { note: flag('note'), sessionId: sess }) ? `asking${DECLARED}` : 'no session record')
|
|
571
|
+
console.log(s.markState('asking', { note: flag('note'), sessionId: sess }) ? `asking${DECLARED}${noteEcho(flag('note'))}` : 'no session record')
|
|
437
572
|
} else if (sub === 'commit-gate') {
|
|
438
573
|
// the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
|
|
439
574
|
// ready to declare done/merge (work committed + ahead of main), else print the reason and exit 1. Uses
|
|
@@ -483,6 +618,32 @@ if (cmd === 'serve') {
|
|
|
483
618
|
const r = await c.clientCapture(full)
|
|
484
619
|
if (r.ok) { process.stdout.write(r.pane) }
|
|
485
620
|
else { console.error(`spex capture: ${r.reason}`); process.exit(r.status === 404 ? 2 : 1) }
|
|
621
|
+
} else if (sub === 'rename') {
|
|
622
|
+
// set the session's display-name override — the right-click rename ([[session-rename]]) as a verb, so an
|
|
623
|
+
// agent manager can fix a label without the GUI. An EXPLICIT "" clears back to the derived label; a
|
|
624
|
+
// MISSING argument is a usage error, never a silent clear. Unknown session → the endpoint's 404, loud.
|
|
625
|
+
const full = await resolveSelectorOrExit(id)
|
|
626
|
+
const name = process.argv[5]
|
|
627
|
+
if (name === undefined) { console.error('usage: spex session rename <SEL> "<name>" (an explicit "" clears the override)'); process.exit(2) }
|
|
628
|
+
if (await c.clientRename(full, name)) console.log(name.trim() ? `${full} -> renamed "${name.trim()}"` : `${full} -> name cleared (derived label restored)`)
|
|
629
|
+
else { console.error(`spex session rename: no such session ${full}`); process.exit(2) }
|
|
630
|
+
} else if (sub === 'rawkey') {
|
|
631
|
+
// forward raw nav-mode keystrokes (tmux send-keys, NEVER the prompt socket) — how a manager drives a
|
|
632
|
+
// worker wedged in an interactive TUI dialog the prompt channel can't reach (a select menu wanting one
|
|
633
|
+
// Enter/arrow). Tokens = named keys, single chars, C-/M-/S- combos; whitespace-separated, delivered as
|
|
634
|
+
// ONE ordered batch ([[nav-mode-key-ordering]]). Fail-loud: nothing delivered exits non-zero.
|
|
635
|
+
const full = await resolveSelectorOrExit(id)
|
|
636
|
+
const keys = process.argv.slice(5).flatMap((s) => s.split(/\s+/)).filter(Boolean)
|
|
637
|
+
if (keys.length === 0) { console.error('usage: spex session rawkey <SEL> "<keys>" (e.g. "Up Up Enter", "C-r", single chars)'); process.exit(2) }
|
|
638
|
+
if (await c.clientRawkey(full, keys)) console.log(`sent ${keys.length} key${keys.length === 1 ? '' : 's'} -> ${full}`)
|
|
639
|
+
else { console.error(`spex session rawkey: nothing delivered to ${full} (offline, unknown session, or no valid key token)`); process.exit(1) }
|
|
640
|
+
} else if (sub === 'attach') {
|
|
641
|
+
// the HUMAN escape hatch (attach.ts, [[session-attach]]): foreground `tmux attach` into the worker's
|
|
642
|
+
// real session. Guards fail loud BEFORE resolving: local-only (the tmux server is the backend
|
|
643
|
+
// machine's) and terminal-only (an agent must never block its turn on it — capture/send/rawkey).
|
|
644
|
+
const { assertLocalBackend, attachSession } = await import('./attach.js')
|
|
645
|
+
await assertLocalBackend()
|
|
646
|
+
await attachSession(await resolveSelectorOrExit(id))
|
|
486
647
|
} else if (sub === 'prompt') {
|
|
487
648
|
// print the session's full ORIGINATING prompt (what it was asked to do), captured at launch.
|
|
488
649
|
const full = await resolveSelectorOrExit(id)
|
|
@@ -490,7 +651,7 @@ if (cmd === 'serve') {
|
|
|
490
651
|
if (!r.ok) { console.error(`no prompt recorded for ${full}`); process.exit(1) }
|
|
491
652
|
process.stdout.write(r.prompt.endsWith('\n') ? r.prompt : r.prompt + '\n')
|
|
492
653
|
} else {
|
|
493
|
-
console.error('spex session: new|reopen|done|park|ask|
|
|
654
|
+
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)
|
|
494
655
|
}
|
|
495
656
|
} else if (cmd === 'internal') {
|
|
496
657
|
// @@@ internal - the machine-plumbing namespace: verbs only generated hooks and launch scripts call,
|
package/spec-cli/src/client.ts
CHANGED
|
@@ -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
|
|
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(`${
|
|
16
|
+
return await fetch(`${base}${path}`, init)
|
|
15
17
|
} catch (e) {
|
|
16
|
-
throw new BackendError(`no backend reachable at ${
|
|
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
|
|
61
|
-
// backend builds (default), or the model JSON (`json:true` → ?format=json). The engine runs on the
|
|
62
|
-
// so the CLI is a thin fetcher that writes/opens these bytes — works against a remote backend
|
|
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> {
|