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