spexcode 0.2.1 → 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 +147 -103
- package/README.zh-CN.md +123 -88
- 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 +214 -62
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/guide.ts +63 -11
- package/spec-cli/src/harness.ts +48 -19
- package/spec-cli/src/help.ts +137 -49
- package/spec-cli/src/index.ts +31 -11
- package/spec-cli/src/issues.ts +48 -21
- package/spec-cli/src/layout.ts +4 -4
- package/spec-cli/src/localIssues.ts +44 -60
- 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-yatsu/src/cli.ts +89 -15
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @@@ session attach - the HUMAN escape hatch into a worker: every session is just a tmux session on the
|
|
2
|
+
// backend's private socket, and the most direct way to see or rescue one is to sit in it. This verb is the
|
|
3
|
+
// sanctioned foreground `tmux attach` — no programmatic exception-handling ambition, the user fixes it by
|
|
4
|
+
// being there. It is deliberately the ONE session verb that does NOT route through the backend
|
|
5
|
+
// ([[remote-client]]'s exception): a terminal cannot be brokered over HTTP, and attaching a tmux CLIENT to
|
|
6
|
+
// the same server is tmux's native multi-client support, not a second actor on the socket. That makes it
|
|
7
|
+
// LOCAL-only by nature — the guards below fail loud (never degrade) when the premise doesn't hold.
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { networkInterfaces } from 'node:os'
|
|
10
|
+
import { alive, apiBase, TMUX_SOCK } from './sessions.js'
|
|
11
|
+
|
|
12
|
+
const AGENT_ALTERNATIVES = 'read the pane with `spex session capture`, drive it with `session send` / `session rawkey`'
|
|
13
|
+
|
|
14
|
+
// attach only makes sense on the machine that runs the tmux server — the backend's. The board the selector
|
|
15
|
+
// resolved against IS that backend, so the test is: does the RESOLVED backend (see [[remote-client]]'s
|
|
16
|
+
// ladder — flag / worker env / cwd record / fallback) point at this machine? Loopback and any address this
|
|
17
|
+
// host owns count as local; anything else (a tailnet/LAN IP of another box, a hostname we can't claim)
|
|
18
|
+
// fails loud with the reason and the remote-capable alternatives, never a silent local fallback onto a
|
|
19
|
+
// tmux socket that holds no sessions.
|
|
20
|
+
export async function assertLocalBackend(): Promise<void> {
|
|
21
|
+
const base = await apiBase()
|
|
22
|
+
let host: string
|
|
23
|
+
try { host = new URL(base).hostname } catch { host = '' }
|
|
24
|
+
const mine = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
|
|
25
|
+
for (const addrs of Object.values(networkInterfaces())) for (const a of addrs ?? []) mine.add(a.address)
|
|
26
|
+
if (mine.has(host)) return
|
|
27
|
+
console.error(`spex session attach: attach is LOCAL-only, and the resolved backend is another machine (${base}).
|
|
28
|
+
The tmux session lives on THAT machine — a terminal can't be attached over HTTP. Either run attach there
|
|
29
|
+
(e.g. over ssh), or ${AGENT_ALTERNATIVES} — those work remotely.`)
|
|
30
|
+
process.exit(2)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// foreground takeover of the session's real tmux window; returns only via detach (C-b d) or the session
|
|
34
|
+
// ending. Interactive and blocking by design — a caller without a terminal (an agent inside its turn, a
|
|
35
|
+
// pipe) is refused up front and pointed at the remote-capable verbs instead of tmux's bare "not a terminal".
|
|
36
|
+
export async function attachSession(id: string): Promise<never> {
|
|
37
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
38
|
+
console.error(`spex session attach: attach is INTERACTIVE and needs a terminal — it blocks until you detach.
|
|
39
|
+
An agent must not run it inside a turn (it freezes you); ${AGENT_ALTERNATIVES}.`)
|
|
40
|
+
process.exit(2)
|
|
41
|
+
}
|
|
42
|
+
if (!(await alive(id))) {
|
|
43
|
+
console.error(`spex session attach: ${id} is offline — no live tmux session to attach.
|
|
44
|
+
Bring it back with \`spex session reopen ${id}\`, or read its record with \`spex ls ${id}\`.`)
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
console.log(`attaching to ${id} — detach with C-b d (the session keeps running)`)
|
|
48
|
+
const r = spawnSync('tmux', ['-u', '-L', TMUX_SOCK, 'attach-session', '-t', id], { stdio: 'inherit' })
|
|
49
|
+
process.exit(r.status ?? 1)
|
|
50
|
+
}
|
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', '--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++) {
|
|
@@ -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.
|
|
@@ -164,25 +201,36 @@ if (cmd === 'serve') {
|
|
|
164
201
|
}
|
|
165
202
|
console.log(text)
|
|
166
203
|
} else if (cmd === 'owner') {
|
|
167
|
-
|
|
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')
|
|
168
207
|
const { loadConfig } = await import('./lint.js')
|
|
169
|
-
const
|
|
170
|
-
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)
|
|
171
211
|
const rel = p.startsWith(process.cwd()) ? p.slice(process.cwd().length + 1) : p
|
|
172
212
|
const owners = specOwners(p)
|
|
213
|
+
const related = specRelated(p)
|
|
173
214
|
const maxOwners = loadConfig(process.cwd()).maxOwners
|
|
174
|
-
|
|
175
|
-
|
|
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.`)
|
|
176
224
|
} else if (owners.length <= maxOwners) {
|
|
177
225
|
// a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
|
|
178
226
|
// silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
|
|
179
227
|
if (has('actionable')) process.exit(0)
|
|
180
228
|
const named = owners.map((o) => `'${o.id}'`).join(', ')
|
|
181
229
|
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
|
|
230
|
+
console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit.${relLine}`)
|
|
183
231
|
} else {
|
|
184
232
|
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)
|
|
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}`)
|
|
186
234
|
}
|
|
187
235
|
} else if (cmd === 'lint') {
|
|
188
236
|
const { specLint, driftGate, DRIFT_GUIDANCE } = await import('./lint.js')
|
|
@@ -200,7 +248,12 @@ if (cmd === 'serve') {
|
|
|
200
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.`)
|
|
201
249
|
process.exit(errors.length || blocked.length ? 1 : 0)
|
|
202
250
|
} else if (cmd === 'ack') {
|
|
203
|
-
//
|
|
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.
|
|
204
257
|
const { git } = await import('./git.js')
|
|
205
258
|
const nodes = positionals(3)
|
|
206
259
|
const reason = (flag('reason') ?? '').trim()
|
|
@@ -210,8 +263,9 @@ if (cmd === 'serve') {
|
|
|
210
263
|
process.exit(2)
|
|
211
264
|
}
|
|
212
265
|
try {
|
|
213
|
-
git(['commit', '--
|
|
214
|
-
|
|
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)`)
|
|
215
269
|
} catch (e: any) {
|
|
216
270
|
console.error(`ack failed: ${e?.message ?? e}`); process.exit(1)
|
|
217
271
|
}
|
|
@@ -226,26 +280,58 @@ if (cmd === 'serve') {
|
|
|
226
280
|
// prose. Git hooks preserved unless --hooks. spex uninstall [targetDir] [--hooks]
|
|
227
281
|
const { uninstall } = await import('./uninstall.js')
|
|
228
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
|
+
}
|
|
229
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.
|
|
230
331
|
const sel = positionals(3)[1]
|
|
231
|
-
if (!sel) { console.error('usage: spex
|
|
232
|
-
|
|
233
|
-
|
|
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)
|
|
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))
|
|
249
335
|
} else if (cmd === 'review') {
|
|
250
336
|
const { clientReview } = await import('./client.js')
|
|
251
337
|
const sel = positionals(3)[0]
|
|
@@ -284,14 +370,16 @@ if (cmd === 'serve') {
|
|
|
284
370
|
const { runYatsu } = await import('../../spec-yatsu/src/cli.js')
|
|
285
371
|
await flushExit(await runYatsu(process.argv.slice(3)))
|
|
286
372
|
} else if (cmd === 'blob') {
|
|
287
|
-
// @@@ blob - the bare evidence-transport
|
|
288
|
-
// 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.
|
|
289
376
|
const { runBlob } = await import('../../spec-yatsu/src/cli.js')
|
|
290
|
-
await flushExit(runBlob(process.argv.slice(3)))
|
|
377
|
+
await flushExit(await runBlob(process.argv.slice(3)))
|
|
291
378
|
} else if (cmd === 'issues') {
|
|
292
379
|
// @@@ 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|
|
|
294
|
-
//
|
|
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
|
|
295
383
|
// thread cross-store. (The pre-rename `spex propose` alias is gone — a deployed post-merge hook still
|
|
296
384
|
// calling it prints an unknown-command line, advisory-only, until `npm run hooks` reinstalls it.)
|
|
297
385
|
const { runIssues } = await import('./issues.js')
|
|
@@ -309,25 +397,61 @@ if (cmd === 'serve') {
|
|
|
309
397
|
// shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
|
|
310
398
|
const { materialize } = await import('./materialize.js')
|
|
311
399
|
console.log(`materialized — content-hash ${materialize()}`)
|
|
312
|
-
} else if (cmd === '
|
|
313
|
-
// @@@
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
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)))
|
|
319
408
|
} else if (cmd === 'board') {
|
|
320
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)')
|
|
321
413
|
console.log(JSON.stringify(await buildBoard(), null, 2))
|
|
322
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)
|
|
323
433
|
} else if (cmd === 'search') {
|
|
324
|
-
const { searchSpecs } = await import('./search.js')
|
|
434
|
+
const { searchSpecs, nearestTitles } = await import('./search.js')
|
|
325
435
|
const query = positionals(3).join(' ')
|
|
326
436
|
if (!query.trim()) { console.error('usage: spex search <query> [--json] [--limit N]'); process.exit(2) }
|
|
327
437
|
const limit = Number(flag('limit')) || 10
|
|
328
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)`) })
|
|
329
|
-
|
|
330
|
-
|
|
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) }
|
|
331
455
|
results.forEach((r, i) => {
|
|
332
456
|
console.log(`${String(i + 1).padStart(2)}. ${r.title} [${r.id}] · score ${r.score}`)
|
|
333
457
|
console.log(` ${r.path}`)
|
|
@@ -379,8 +503,10 @@ if (cmd === 'serve') {
|
|
|
379
503
|
// createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
|
|
380
504
|
// it falls back to an in-process launch only when no backend answers.
|
|
381
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) }
|
|
382
507
|
const prompt = flag('prompt') ?? positionals(3)[0] ?? ''
|
|
383
|
-
const
|
|
508
|
+
const nodeArg = flag('node')
|
|
509
|
+
const created = await createSession(nodeArg ? stripRefSigil(nodeArg) : null, prompt, flag('launcher') ?? undefined)
|
|
384
510
|
console.log(JSON.stringify(created, null, 2))
|
|
385
511
|
await launchMonitorReminder(created.id)
|
|
386
512
|
} else if (cmd === 'session') {
|
|
@@ -399,14 +525,14 @@ if (cmd === 'serve') {
|
|
|
399
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.'
|
|
400
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.
|
|
401
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.'
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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') {
|
|
410
536
|
// bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
|
|
411
537
|
// `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts reopen()). The
|
|
412
538
|
// RESUME GUARD refuses a relaunch on a LIVE/unproven agent (that would kill a live worker) — `--force`
|
|
@@ -419,7 +545,7 @@ if (cmd === 'serve') {
|
|
|
419
545
|
// the agent authors ITS OWN state: active|awaiting|parked|error [--propose] [--note] [--session]
|
|
420
546
|
const st = process.argv[4] as any
|
|
421
547
|
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)')
|
|
548
|
+
console.log(ok ? `state -> ${st}${noteEcho(flag('note'))}` : 'no session record (unknown --session / no CLAUDE_CODE_SESSION_ID, or bad status)')
|
|
423
549
|
} else if (sub === 'done') {
|
|
424
550
|
// sugar for awaiting; --propose merge|nothing|close, optional --note
|
|
425
551
|
const p = (flag('propose') as any) || 'nothing'
|
|
@@ -431,10 +557,10 @@ if (cmd === 'serve') {
|
|
|
431
557
|
try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
|
|
432
558
|
catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
|
|
433
559
|
}
|
|
434
|
-
console.log(s.markDone(p, sess) ? `done (${p})${DECLARED}${closeNote}` : 'no session record')
|
|
560
|
+
console.log(s.markDone(p, sess, flag('note')) ? `done (${p})${DECLARED}${noteEcho(flag('note'))}${closeNote}` : 'no session record')
|
|
435
561
|
} else if (sub === 'park') {
|
|
436
562
|
// 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')
|
|
563
|
+
console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}${noteEcho(flag('note'))}` : 'no session record')
|
|
438
564
|
} else if (sub === 'fail') {
|
|
439
565
|
// the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
|
|
440
566
|
console.log(s.markError(sess) ? 'marked error' : 'no session record')
|
|
@@ -442,7 +568,7 @@ if (cmd === 'serve') {
|
|
|
442
568
|
// the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
|
|
443
569
|
// authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
|
|
444
570
|
// (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')
|
|
571
|
+
console.log(s.markState('asking', { note: flag('note'), sessionId: sess }) ? `asking${DECLARED}${noteEcho(flag('note'))}` : 'no session record')
|
|
446
572
|
} else if (sub === 'commit-gate') {
|
|
447
573
|
// the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
|
|
448
574
|
// ready to declare done/merge (work committed + ahead of main), else print the reason and exit 1. Uses
|
|
@@ -492,6 +618,32 @@ if (cmd === 'serve') {
|
|
|
492
618
|
const r = await c.clientCapture(full)
|
|
493
619
|
if (r.ok) { process.stdout.write(r.pane) }
|
|
494
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))
|
|
495
647
|
} else if (sub === 'prompt') {
|
|
496
648
|
// print the session's full ORIGINATING prompt (what it was asked to do), captured at launch.
|
|
497
649
|
const full = await resolveSelectorOrExit(id)
|
|
@@ -499,7 +651,7 @@ if (cmd === 'serve') {
|
|
|
499
651
|
if (!r.ok) { console.error(`no prompt recorded for ${full}`); process.exit(1) }
|
|
500
652
|
process.stdout.write(r.prompt.endsWith('\n') ? r.prompt : r.prompt + '\n')
|
|
501
653
|
} else {
|
|
502
|
-
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)
|
|
503
655
|
}
|
|
504
656
|
} else if (cmd === 'internal') {
|
|
505
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> {
|