spexcode 0.1.0
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/bin/spex.mjs +15 -0
- package/dashboard-dist/assets/index-B60MILFg.js +139 -0
- package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
- package/dashboard-dist/index.html +16 -0
- package/package.json +35 -0
- package/src/board.ts +119 -0
- package/src/cli.ts +487 -0
- package/src/client.ts +102 -0
- package/src/gateway.ts +241 -0
- package/src/git.ts +492 -0
- package/src/guide.ts +134 -0
- package/src/harness.ts +674 -0
- package/src/hooks.ts +41 -0
- package/src/index.ts +233 -0
- package/src/init.ts +120 -0
- package/src/layout.ts +246 -0
- package/src/lint.ts +206 -0
- package/src/login-page.ts +79 -0
- package/src/materialize.ts +85 -0
- package/src/pty-bridge.ts +235 -0
- package/src/ranker.ts +129 -0
- package/src/resilience.ts +41 -0
- package/src/search.bench.mjs +47 -0
- package/src/search.ts +24 -0
- package/src/self.ts +256 -0
- package/src/sessions.ts +1469 -0
- package/src/slash-commands.ts +242 -0
- package/src/specs.ts +331 -0
- package/src/supervise.ts +158 -0
- package/src/uploads.ts +31 -0
- package/templates/hooks/pre-commit +57 -0
- package/templates/hooks/prepare-commit-msg +14 -0
- package/templates/spec/project/.config/core/idle/idle.sh +15 -0
- package/templates/spec/project/.config/core/idle/spec.md +13 -0
- package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
- package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
- package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
- package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
- package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
- package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
- package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
- package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
- package/templates/spec/project/.config/core/spec.md +13 -0
- package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
- package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
- package/templates/spec/project/.config/extract/spec.md +60 -0
- package/templates/spec/project/.config/forge-link/spec.md +9 -0
- package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
- package/templates/spec/project/.config/regroup/spec.md +25 -0
- package/templates/spec/project/.config/scenario/spec.md +32 -0
- package/templates/spec/project/.config/spec.md +15 -0
- package/templates/spec/project/.config/supervisor/spec.md +8 -0
- package/templates/spec/project/.config/tidy/spec.md +25 -0
- package/templates/spec/project/spec.md +19 -0
- package/templates/spexcode.json +5 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
export {} // make this a module so top-level await is allowed
|
|
2
|
+
const cmd = process.argv[2]
|
|
3
|
+
|
|
4
|
+
// registered before any await so a top-level-await rejection lands here; BackendError matched by name to avoid importing it.
|
|
5
|
+
process.on('unhandledRejection', (e: unknown) => {
|
|
6
|
+
if (e instanceof Error && e.name === 'BackendError') console.error(`spex: ${e.message}`)
|
|
7
|
+
else console.error(e)
|
|
8
|
+
process.exit(1)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
// tiny flag reader: --key value (and bare positionals)
|
|
12
|
+
function flag(name: string): string | undefined {
|
|
13
|
+
const i = process.argv.indexOf(`--${name}`)
|
|
14
|
+
return i >= 0 ? process.argv[i + 1] : undefined
|
|
15
|
+
}
|
|
16
|
+
const has = (name: string) => process.argv.includes(`--${name}`)
|
|
17
|
+
// bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
|
|
18
|
+
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--harness-session', '--port', '--api-port'])
|
|
19
|
+
function positionals(from: number): string[] {
|
|
20
|
+
const out: string[] = []
|
|
21
|
+
for (let i = from; i < process.argv.length; i++) {
|
|
22
|
+
const t = process.argv[i]
|
|
23
|
+
if (t.startsWith('--')) { if (VALUE_FLAGS.has(t)) i++; continue }
|
|
24
|
+
out.push(t)
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const greeted = new Set<string>()
|
|
30
|
+
async function greetWatchTargets(watcher: string, selectors: string[]): Promise<void> {
|
|
31
|
+
try {
|
|
32
|
+
const real = selectors.filter((sel) => sel && sel !== '@all')
|
|
33
|
+
if (!real.length) return
|
|
34
|
+
const { resolveClientSession, clientSend } = await import('./client.js')
|
|
35
|
+
const { sessionHeadline } = await import('./sessions.js')
|
|
36
|
+
const meR = await resolveClientSession(watcher)
|
|
37
|
+
// name the watcher by its board HEADLINE (same as the reply-channel footer), delimited as a session title.
|
|
38
|
+
const me = 'ok' in meR ? sessionHeadline(meR.ok) : watcher
|
|
39
|
+
const meWho = me && me !== watcher ? `session "${me}" (${watcher})` : `session ${watcher}`
|
|
40
|
+
for (const sel of real) {
|
|
41
|
+
const r = await resolveClientSession(sel)
|
|
42
|
+
if (!('ok' in r)) continue // none/ambiguous → don't guess a target to interrupt
|
|
43
|
+
const target = r.ok.id
|
|
44
|
+
if (target === watcher || greeted.has(target)) continue
|
|
45
|
+
greeted.add(target)
|
|
46
|
+
const text = `🔭 ${meWho} is now supervising you — they started \`spex watch\` over this session. To reach them directly, run: spex session send ${watcher} "<your message>". (One-time heads-up; reply only if you need to.)`
|
|
47
|
+
void clientSend(target, text) // no sender id → the connection notice is not double-counted as comms
|
|
48
|
+
}
|
|
49
|
+
} catch { /* greeting is best-effort — it must never disturb the watch */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function withWatchEdge<T>(selectors: string[], intervalMs: number, body: () => Promise<T>, greet = false): Promise<T> {
|
|
53
|
+
const { ownSessionId, reportWatch, reportUnwatch } = await import('./sessions.js')
|
|
54
|
+
const { randomUUID } = await import('node:crypto')
|
|
55
|
+
const watcher = ownSessionId()
|
|
56
|
+
if (!watcher) return body() // not a launched session (no own id) → nothing to attribute an edge to
|
|
57
|
+
const token = randomUUID()
|
|
58
|
+
const ttlMs = intervalMs * 3 // tolerate two missed heartbeats before the edge is dropped
|
|
59
|
+
void reportWatch(token, watcher, selectors, ttlMs)
|
|
60
|
+
if (greet) void greetWatchTargets(watcher, selectors) // one-shot connection handshake to specific targets
|
|
61
|
+
const hb = setInterval(() => void reportWatch(token, watcher, selectors, ttlMs), intervalMs)
|
|
62
|
+
const cleanup = () => { clearInterval(hb); void reportUnwatch(token) }
|
|
63
|
+
process.once('SIGINT', () => { cleanup(); process.exit(0) })
|
|
64
|
+
process.once('SIGTERM', () => { cleanup(); process.exit(0) })
|
|
65
|
+
try { return await body() } finally { cleanup() } // one-shot `wait` clears on return; stream `watch` clears on signal
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function resolveSelectorOrExit(selector: string): Promise<string> {
|
|
69
|
+
if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch)'); process.exit(2) }
|
|
70
|
+
const { resolveClientSession } = await import('./client.js')
|
|
71
|
+
const r = await resolveClientSession(selector)
|
|
72
|
+
if ('ok' in r) return r.ok.id
|
|
73
|
+
if ('none' in r) { console.error(`spex: no such session: ${selector}`); process.exit(2) }
|
|
74
|
+
console.error(`spex: ambiguous selector "${selector}" matches ${r.ambiguous.length} sessions — be more specific:`)
|
|
75
|
+
for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${s.node || s.branch || s.id}`)
|
|
76
|
+
process.exit(2)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function printHelp(): void {
|
|
80
|
+
console.log(`spex — SpexCode CLI (spec↔code graph + worktree session state machine)
|
|
81
|
+
|
|
82
|
+
Usage: spex <command> [args]
|
|
83
|
+
|
|
84
|
+
Specs / graph
|
|
85
|
+
guide [spec|yatsu] no topic: the setup workflow; spec/yatsu: the file-format manual for authoring nodes
|
|
86
|
+
init [dir] scaffold a repo to adopt SpexCode (seed .spec + install git hooks; default: cwd)
|
|
87
|
+
lint check the spec↔code graph (integrity·living·coverage·drift); when committing, gates on heavy commit-local drift
|
|
88
|
+
ack <node>… --reason stamp Spec-OK on HEAD for one or more nodes (this change keeps their specs valid); --reason required, not stored
|
|
89
|
+
serve run the API server (default :8787). [--port N] sets the listen port (mirrors
|
|
90
|
+
dashboard --api-port, so many projects coexist on one host — cwd picks the project)
|
|
91
|
+
--public --password <pw> expose it on a public IP behind a password + self-signed TLS (no domain
|
|
92
|
+
needed). [--tls-cert F --tls-key F] for your own cert · [--http] to drop TLS
|
|
93
|
+
dashboard serve the dashboard UI on its own port (default 5173), proxying /api to a running
|
|
94
|
+
\`spex serve\`. [--port N] [--api-port N=8787]. The installed replacement for \`npm run web\`.
|
|
95
|
+
board dump the dashboard board state as JSON
|
|
96
|
+
forge <sub> trace a forge's issues/PRs onto spec nodes (read-only): links | eval-pending [--host github] [--node <id>] [--json]
|
|
97
|
+
yatsu <sub> measure a node's scenarios and keep score: scan | eval [.|<node>] [--scenario N] (--pass|--fail|--note T) [--image P|--result P|-] | show [.|<node>] [--json] | clean [--keep-latest|--all]
|
|
98
|
+
hooks <sub> harness-agnostic hook system: compile [--out <file>] (flatten surface:hook nodes into the per-session manifest the dispatcher reads)
|
|
99
|
+
self <sub> diagnose how the workflow reaches THIS self-launched agent: doctor (default) | contract | env
|
|
100
|
+
review <SEL> manager cockpit: review a session (ahead·merge-base diff·gates·proposal) [--json]
|
|
101
|
+
review proof <SEL> render the session's proof of work — self-contained HTML, fully derived (diff·measured yatsu loss·gates) [--open|--out P|--json]
|
|
102
|
+
merge <SEL> manager cockpit: gated atomic merge into main (re-checks gates, then closes) [--keep]
|
|
103
|
+
|
|
104
|
+
Sessions
|
|
105
|
+
ls [SEL…] living-sessions table [--status a,b] [--json]
|
|
106
|
+
watch [SEL…] stream actionable transitions — NEVER EXITS; run it in the BACKGROUND, don't block a turn on it (poll one-shot with \`wait\`) [--as NAME] [--status a,b] [--idle] [--interval N]
|
|
107
|
+
wait <SEL> block until <SEL> is actionable, print it, exit (one-shot — the non-blocking counterpart to watch; draws the graph edge) [--timeout S=1200] [--interval S]
|
|
108
|
+
new "<prompt>" start a session (= session new) [--node X]
|
|
109
|
+
session <sub> new | list | reopen | review | done | merge | exit | close | send | capture | prompt
|
|
110
|
+
session prompt <SEL> print the session's originating prompt (what it was asked to do)
|
|
111
|
+
|
|
112
|
+
SEL = session id (or id-prefix), node, or branch — accepted by every read/control verb (ls·watch·wait·
|
|
113
|
+
review·merge·reopen·exit·close·send·capture·prompt); none (or @all) = every session.`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// a trailing --help/-h prints the summary and exits BEFORE any verb runs, so a help probe never fires a streaming/mutating command.
|
|
117
|
+
if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
|
|
118
|
+
printHelp()
|
|
119
|
+
process.exit(0)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (cmd === 'serve') {
|
|
123
|
+
// the supervisor owns the public port and runs index.ts as a child for zero-downtime reloads; it
|
|
124
|
+
// (not `tsx watch`) is what watches spec-cli/src, so the package `serve` script must NOT use --watch.
|
|
125
|
+
// --port is sugar over the PORT env supervise.ts reads — set BEFORE importing so it takes effect. This
|
|
126
|
+
// mirrors `spex dashboard --api-port`, so one host runs many projects (each `serve --port N` paired with
|
|
127
|
+
// a `dashboard --api-port N`), cwd picking which project's .spec is served — no shared default collides.
|
|
128
|
+
const portArg = flag('port')
|
|
129
|
+
if (portArg !== undefined) {
|
|
130
|
+
if (!Number.isInteger(Number(portArg))) { console.error('spex serve: --port must be an integer'); process.exit(2) }
|
|
131
|
+
process.env.PORT = portArg
|
|
132
|
+
}
|
|
133
|
+
await import('./supervise.js')
|
|
134
|
+
} else if (cmd === 'dashboard') {
|
|
135
|
+
// the natural post-install UI: serve the bundled dashboard on its OWN loopback port, proxying /api +
|
|
136
|
+
// the terminal socket to a separately-run `spex serve`. Replaces the dogfood-only `npm run web` (vite).
|
|
137
|
+
const { serveDashboardLocal } = await import('./gateway.js')
|
|
138
|
+
const port = Number(flag('port') ?? process.env.SPEXCODE_DASHBOARD_PORT ?? 5173)
|
|
139
|
+
const apiPort = Number(flag('api-port') ?? process.env.PORT ?? 8787)
|
|
140
|
+
if (!Number.isInteger(port) || !Number.isInteger(apiPort)) { console.error('spex dashboard: --port and --api-port must be integers'); process.exit(2) }
|
|
141
|
+
serveDashboardLocal({ port, apiPort })
|
|
142
|
+
} else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
143
|
+
printHelp()
|
|
144
|
+
} else if (cmd === 'guide') {
|
|
145
|
+
const { guideText } = await import('./guide.js')
|
|
146
|
+
console.log(guideText(process.argv[3]))
|
|
147
|
+
} else if (cmd === 'owner') {
|
|
148
|
+
const { specOwners } = await import('./specs.js')
|
|
149
|
+
const { loadConfig } = await import('./lint.js')
|
|
150
|
+
const p = positionals(3)[0]
|
|
151
|
+
if (!p) { console.error('usage: spex owner <path>'); process.exit(2) }
|
|
152
|
+
const rel = p.startsWith(process.cwd()) ? p.slice(process.cwd().length + 1) : p
|
|
153
|
+
const owners = specOwners(p)
|
|
154
|
+
const maxOwners = loadConfig(process.cwd()).maxOwners
|
|
155
|
+
if (owners.length === 0) {
|
|
156
|
+
console.log(`${rel} — no spec governs this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
|
|
157
|
+
} else if (owners.length <= maxOwners) {
|
|
158
|
+
// a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
|
|
159
|
+
// silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
|
|
160
|
+
if (has('actionable')) process.exit(0)
|
|
161
|
+
const named = owners.map((o) => `'${o.id}'`).join(', ')
|
|
162
|
+
const lead = owners.length === 1 ? `${rel} is governed by ${named} — ${owners[0].desc}` : `${rel} is governed by ${named} (shared, fine).`
|
|
163
|
+
console.log(`${lead} Read/honor the spec; if your change shifts the intent, update the spec in the SAME commit.`)
|
|
164
|
+
} else {
|
|
165
|
+
const ids = owners.map((o) => o.id).join(', ')
|
|
166
|
+
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).`)
|
|
167
|
+
}
|
|
168
|
+
} else if (cmd === 'lint') {
|
|
169
|
+
const { specLint, driftGate, DRIFT_GUIDANCE } = await import('./lint.js')
|
|
170
|
+
const findings = await specLint()
|
|
171
|
+
const errors = findings.filter((f) => f.level === 'error')
|
|
172
|
+
for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
|
|
173
|
+
console.error(`spex lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
|
|
174
|
+
// drift teaches + gates from the ONE `spex lint` (no flag): print the remediation guidance wherever
|
|
175
|
+
// drift exists, then apply the commit-local gate — which reads the staged index itself, so it only
|
|
176
|
+
// blocks an in-flight commit that touches an already heavily-drifted node. CI/manual (nothing staged)
|
|
177
|
+
// stays advisory, per the ci-gate contract.
|
|
178
|
+
const { blocked, touched, threshold } = await driftGate()
|
|
179
|
+
if (findings.some((f) => f.rule === 'drift') || touched.length) console.error(`\n${DRIFT_GUIDANCE}`)
|
|
180
|
+
for (const t of touched) console.error(` ${t.drift >= threshold ? '✗' : '•'} drift-gate: '${t.id}' is ${t.drift} behind${t.drift >= threshold ? ' — BLOCKS this commit' : ' (advisory)'}`)
|
|
181
|
+
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.`)
|
|
182
|
+
process.exit(errors.length || blocked.length ? 1 : 0)
|
|
183
|
+
} else if (cmd === 'ack') {
|
|
184
|
+
// --amend stamps the Spec-OK trailers in the same block as Session:; git de-dupes adjacent trailers, so re-acking is harmless.
|
|
185
|
+
const { git } = await import('./git.js')
|
|
186
|
+
const nodes = positionals(3)
|
|
187
|
+
const reason = (flag('reason') ?? '').trim()
|
|
188
|
+
if (!nodes.length || !reason) {
|
|
189
|
+
console.error('usage: spex ack <node-id>… --reason "<why this change keeps each spec valid>"')
|
|
190
|
+
console.error(' --reason is required (it forces you to check before acking) and is NOT stored — git keeps only the Spec-OK trailer.')
|
|
191
|
+
process.exit(2)
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
git(['commit', '--amend', '--no-edit', ...nodes.flatMap((n) => ['--trailer', `Spec-OK: ${n}`])])
|
|
195
|
+
console.log(`Spec-OK: ${nodes.join(', ')} → ${git(['rev-parse', '--short', 'HEAD']).trim()} (reason required, not stored)`)
|
|
196
|
+
} catch (e: any) {
|
|
197
|
+
console.error(`ack failed: ${e?.message ?? e}`); process.exit(1)
|
|
198
|
+
}
|
|
199
|
+
} else if (cmd === 'init') {
|
|
200
|
+
// scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
|
|
201
|
+
// into <targetDir> (default cwd). spex init [targetDir]
|
|
202
|
+
const { specInit } = await import('./init.js')
|
|
203
|
+
await specInit(positionals(3)[0])
|
|
204
|
+
} else if (cmd === 'review' && positionals(3)[0] === 'proof') {
|
|
205
|
+
const sel = positionals(3)[1]
|
|
206
|
+
if (!sel) { console.error('usage: spex review proof <selector> [--open | --out <path> | --json]'); process.exit(2) }
|
|
207
|
+
const id = await resolveSelectorOrExit(sel)
|
|
208
|
+
const { clientProof } = await import('./client.js')
|
|
209
|
+
const r = await clientProof(id, has('json'))
|
|
210
|
+
if (!r.ok) { console.error(`no proof for ${id} (status ${r.status})`); process.exit(1) }
|
|
211
|
+
if (has('json')) { console.log(r.body); process.exit(0) }
|
|
212
|
+
const { writeFileSync } = await import('node:fs')
|
|
213
|
+
const { join } = await import('node:path')
|
|
214
|
+
const { tmpdir } = await import('node:os')
|
|
215
|
+
const out = flag('out') ?? join(tmpdir(), `spexcode-proof-${id.slice(0, 8)}.html`)
|
|
216
|
+
writeFileSync(out, r.body)
|
|
217
|
+
if (has('open')) {
|
|
218
|
+
const { spawn } = await import('node:child_process')
|
|
219
|
+
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
220
|
+
try { spawn(opener, [out], { detached: true, stdio: 'ignore' }).unref(); console.log(`opened ${out}`) }
|
|
221
|
+
catch { console.log(`wrote ${out} — couldn't auto-open, open it in a browser`) }
|
|
222
|
+
} else console.log(out)
|
|
223
|
+
process.exit(0)
|
|
224
|
+
} else if (cmd === 'review') {
|
|
225
|
+
const { clientReview } = await import('./client.js')
|
|
226
|
+
const sel = positionals(3)[0]
|
|
227
|
+
if (!sel) { console.error('usage: spex review <session-selector> (id | id-prefix | node | branch)'); process.exit(2) }
|
|
228
|
+
const id = await resolveSelectorOrExit(sel)
|
|
229
|
+
const r = await clientReview(id)
|
|
230
|
+
if (!r) { console.error(`no such session ${id}`); process.exit(1) }
|
|
231
|
+
if (has('json')) { console.log(JSON.stringify(r, null, 2)) }
|
|
232
|
+
else {
|
|
233
|
+
const g = r.gates
|
|
234
|
+
console.log(`review ${r.node || r.branch || r.id} [${r.id}]`)
|
|
235
|
+
console.log(` ahead of main : ${r.ahead} commit(s)`)
|
|
236
|
+
console.log(` uncommitted : ${r.dirtyNonRuntime} non-runtime file(s)`)
|
|
237
|
+
console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
|
|
238
|
+
console.log(' gates:')
|
|
239
|
+
console.log(` conflicts w/ main : ${g.conflictsWithMain ? 'YES' : 'no'}`)
|
|
240
|
+
console.log(` typecheck : ${g.typecheck.ok ? 'ok' : `${g.typecheck.errorCount} error(s)`}`)
|
|
241
|
+
console.log(` lint : ${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)`)
|
|
242
|
+
console.log(` diff (merge-base, ${r.diff.length} file(s)):`)
|
|
243
|
+
for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
|
|
244
|
+
}
|
|
245
|
+
} else if (cmd === 'merge') {
|
|
246
|
+
const { clientMerge } = await import('./client.js')
|
|
247
|
+
const sel = positionals(3)[0]
|
|
248
|
+
if (!sel) { console.error('usage: spex merge <selector> (id | id-prefix | node | branch)'); process.exit(2) }
|
|
249
|
+
const id = await resolveSelectorOrExit(sel)
|
|
250
|
+
const r = await clientMerge(id)
|
|
251
|
+
if (r.dispatched) console.log(`merge dispatched to ${id} — its agent is landing the merge`)
|
|
252
|
+
else console.error(`merge dispatch failed: ${r.reason}`)
|
|
253
|
+
process.exit(r.dispatched ? 0 : 1)
|
|
254
|
+
} else if (cmd === 'forge') {
|
|
255
|
+
// thin route — all logic lives in spec-forge.
|
|
256
|
+
const { runForge } = await import('../../spec-forge/src/cli.js')
|
|
257
|
+
process.exit(await runForge(process.argv.slice(3)))
|
|
258
|
+
} else if (cmd === 'yatsu') {
|
|
259
|
+
// thin route — all logic lives in spec-yatsu.
|
|
260
|
+
const { runYatsu } = await import('../../spec-yatsu/src/cli.js')
|
|
261
|
+
process.exit(await runYatsu(process.argv.slice(3)))
|
|
262
|
+
} else if (cmd === 'hooks') {
|
|
263
|
+
// @@@ hooks - compile the surface:hook nodes into the per-session manifest the (pure-shell) dispatcher
|
|
264
|
+
// reads. Thin route, like forge/yatsu. `spex hooks compile [--out <file>]`. Logic in hooks.ts.
|
|
265
|
+
const { runHooks } = await import('./hooks.js')
|
|
266
|
+
process.exit(await runHooks(process.argv.slice(3)))
|
|
267
|
+
} else if (cmd === 'materialize') {
|
|
268
|
+
// @@@ materialize - the pay-per-change render: surface nodes → manifest + AGENTS.md/CLAUDE.md block +
|
|
269
|
+
// shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
|
|
270
|
+
const { materialize } = await import('./materialize.js')
|
|
271
|
+
console.log(`materialized — content-hash ${materialize()}`)
|
|
272
|
+
} else if (cmd === 'self') {
|
|
273
|
+
// @@@ self - the self-diagnosis surface (spec-cli/self): does the materialized workflow actually reach
|
|
274
|
+
// THIS self-launched agent? doctor reports per-layer coverage (preconditions · git-hook floor · contract ·
|
|
275
|
+
// hooks+handler-existence · backend) over the same HARNESSES materialize renders through; contract prints
|
|
276
|
+
// the surface:system text; env dumps raw facts. Thin route, like forge/yatsu/hooks.
|
|
277
|
+
const { runSelf } = await import('./self.js')
|
|
278
|
+
process.exit(await runSelf(process.argv.slice(3)))
|
|
279
|
+
} else if (cmd === 'board') {
|
|
280
|
+
const { buildBoard } = await import('./board.js')
|
|
281
|
+
console.log(JSON.stringify(await buildBoard(), null, 2))
|
|
282
|
+
} else if (cmd === 'search') {
|
|
283
|
+
const { searchSpecs } = await import('./search.js')
|
|
284
|
+
const query = positionals(3).join(' ')
|
|
285
|
+
if (!query.trim()) { console.error('usage: spex search <query> [--json] [--limit N]'); process.exit(2) }
|
|
286
|
+
const limit = Number(flag('limit')) || 10
|
|
287
|
+
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)`) })
|
|
288
|
+
if (has('json')) { console.log(JSON.stringify(results)); process.exit(0) }
|
|
289
|
+
if (!results.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
|
|
290
|
+
results.forEach((r, i) => {
|
|
291
|
+
console.log(`${String(i + 1).padStart(2)}. ${r.title} [${r.id}] · score ${r.score}`)
|
|
292
|
+
console.log(` ${r.path}`)
|
|
293
|
+
if (r.snippet) console.log(` ${r.snippet}`)
|
|
294
|
+
})
|
|
295
|
+
process.exit(0)
|
|
296
|
+
} else if (cmd === 'ls' || cmd === 'sessions') {
|
|
297
|
+
// pretty list of living sessions + states. `spex ls [SEL...] [--status a,b] [--json]`
|
|
298
|
+
// the board comes from the backend (so `spex ls` shows the sessions of whatever SPEXCODE_API_URL points at,
|
|
299
|
+
// incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
|
|
300
|
+
const { selectSessions, formatTable } = await import('./sessions.js')
|
|
301
|
+
const { clientListSessions } = await import('./client.js')
|
|
302
|
+
const picked = selectSessions(await clientListSessions(), positionals(3), flag('status')?.split(','))
|
|
303
|
+
console.log(has('json') ? JSON.stringify(picked, null, 2) : formatTable(picked))
|
|
304
|
+
} else if (cmd === 'watch') {
|
|
305
|
+
const { watchSessions } = await import('./sessions.js')
|
|
306
|
+
const { clientListSessions } = await import('./client.js')
|
|
307
|
+
const selectors = positionals(3)
|
|
308
|
+
const intervalMs = (Number(flag('interval')) || 5) * 1000
|
|
309
|
+
await withWatchEdge(selectors, intervalMs, () => watchSessions((line) => console.log(line), {
|
|
310
|
+
source: clientListSessions, // poll the backend, so watch streams the (possibly remote) backend's board
|
|
311
|
+
selectors,
|
|
312
|
+
statuses: flag('status')?.split(','),
|
|
313
|
+
includeIdle: has('idle'),
|
|
314
|
+
as: flag('as'),
|
|
315
|
+
intervalMs,
|
|
316
|
+
}), true) // greet=true: a stream watch greets its specific targets once; `wait` (one-shot) does not
|
|
317
|
+
} else if (cmd === 'wait') {
|
|
318
|
+
const { watchSessions } = await import('./sessions.js')
|
|
319
|
+
const { clientListSessions } = await import('./client.js')
|
|
320
|
+
const [id] = positionals(3)
|
|
321
|
+
if (!id) { console.error('usage: spex wait <id> [--timeout SECONDS] [--interval SECONDS] [--idle]'); process.exit(2) }
|
|
322
|
+
const intervalMs = (Number(flag('interval')) || 2) * 1000
|
|
323
|
+
const timeoutSec = Number(flag('timeout')) || 1200
|
|
324
|
+
const r = await withWatchEdge([id], intervalMs, () => watchSessions(() => {}, {
|
|
325
|
+
source: clientListSessions,
|
|
326
|
+
selectors: [id],
|
|
327
|
+
includeIdle: has('idle'),
|
|
328
|
+
intervalMs,
|
|
329
|
+
until: { timeoutMs: timeoutSec * 1000 },
|
|
330
|
+
}))
|
|
331
|
+
if ('reached' in r) { console.log(r.reached); process.exit(0) }
|
|
332
|
+
if ('gone' in r) { console.error(`spex wait: no such (living) session ${id}`); process.exit(2) }
|
|
333
|
+
if ('backendDown' in r) { console.error(`spex wait: ${r.backendDown}`); process.exit(1) } // fail loud, not a false timeout
|
|
334
|
+
console.error(`spex wait: timeout — ${id} did not reach an actionable status within ${timeoutSec}s`)
|
|
335
|
+
process.exit(1)
|
|
336
|
+
} else if (cmd === 'new') {
|
|
337
|
+
// shorthand for `spex session new`: spex new "<prompt>" [--node X] (prompt = first positional or --prompt)
|
|
338
|
+
// createSession POSTs to the running backend so the launch runs in the backend's process (auth env + cap);
|
|
339
|
+
// it falls back to an in-process launch only when no backend answers.
|
|
340
|
+
const { createSession } = await import('./sessions.js')
|
|
341
|
+
const prompt = flag('prompt') ?? positionals(3)[0] ?? ''
|
|
342
|
+
console.log(JSON.stringify(await createSession(flag('node') ?? null, prompt, flag('harness') ?? undefined), null, 2))
|
|
343
|
+
} else if (cmd === 'session') {
|
|
344
|
+
const sub = process.argv[3]
|
|
345
|
+
// `s` (sessions.ts) backs the state PRODUCERS that stay local (state/done/park/fail/ask/idle write the
|
|
346
|
+
// global record by session_id; commit-gate is a pure git check of the cwd worktree) and `new` (its own
|
|
347
|
+
// launch path). `c` (client.ts) backs the read/control subs that route through the backend. Lazily imported.
|
|
348
|
+
const s = await import('./sessions.js')
|
|
349
|
+
const c = await import('./client.js')
|
|
350
|
+
const id = process.argv[4]
|
|
351
|
+
// the agent-authored state writers resolve WHICH session by id: a `--session <id>` flag (the lifecycle
|
|
352
|
+
// hooks pass it, parsed from the payload, since they no longer have a cwd `.session`) wins, else the
|
|
353
|
+
// harness env var (ownSessionId — the agent's own `spex session …` carries the harness session id).
|
|
354
|
+
const sess = flag('session')
|
|
355
|
+
// appended to a done/ask/block declaration: states (not commands) that the next tool call's mark-active hook re-flips the global record to active, so a re-read won't show this.
|
|
356
|
+
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.'
|
|
357
|
+
if (sub === 'new') {
|
|
358
|
+
// route through the backend (auth env + concurrency cap); in-process only if no backend is reachable.
|
|
359
|
+
// prompt = --prompt OR the first positional (after `session new`), so `session new "<prompt>"` works the
|
|
360
|
+
// SAME as the `spex new "<prompt>"` shorthand — one prompt-resolution rule, not two.
|
|
361
|
+
console.log(JSON.stringify(await s.createSession(flag('node') ?? null, flag('prompt') ?? positionals(4)[0] ?? '', flag('harness') ?? undefined), null, 2))
|
|
362
|
+
} else if (sub === 'list') {
|
|
363
|
+
console.log(JSON.stringify(await c.clientListSessions(), null, 2))
|
|
364
|
+
} else if (sub === 'reopen' || sub === 'resume') {
|
|
365
|
+
// "back to working": clear proposal -> active, relaunch if offline (the backend owns the relaunch)
|
|
366
|
+
const full = await resolveSelectorOrExit(id)
|
|
367
|
+
console.log(await c.clientReopen(full) ? `${full} -> working` : `no such session ${full}`)
|
|
368
|
+
} else if (sub === 'review') {
|
|
369
|
+
console.log(await s.propose(id, 'merge') ? `${id} -> review` : `no such session ${id}`)
|
|
370
|
+
} else if (sub === 'state') {
|
|
371
|
+
// the agent authors ITS OWN state: active|awaiting|parked|error [--propose] [--note] [--session]
|
|
372
|
+
const st = process.argv[4] as any
|
|
373
|
+
const ok = s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess })
|
|
374
|
+
console.log(ok ? `state -> ${st}` : 'no session record (unknown --session / no CLAUDE_CODE_SESSION_ID, or bad status)')
|
|
375
|
+
} else if (sub === 'done') {
|
|
376
|
+
// sugar for awaiting; --propose merge|nothing|close, optional --note
|
|
377
|
+
const p = (flag('propose') as any) || 'nothing'
|
|
378
|
+
console.log(s.markDone(p, sess) ? `done (${p})${DECLARED}` : 'no session record')
|
|
379
|
+
} else if (sub === 'park') {
|
|
380
|
+
// sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
|
|
381
|
+
console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}` : 'no session record')
|
|
382
|
+
} else if (sub === 'fail') {
|
|
383
|
+
// the StopFailure hook marks its session (--session from the payload) as error (turn died on an API error)
|
|
384
|
+
console.log(s.markError(sess) ? 'marked error' : 'no session record')
|
|
385
|
+
} else if (sub === 'ask') {
|
|
386
|
+
// the agent DELIBERATELY declares it is pausing to ask the human a question (like `done`/`park`, an
|
|
387
|
+
// authored state — NOT guarded active-only). The --note carries the question. Distinct from `park`
|
|
388
|
+
// (waiting on a background task, self-resumes): an asking agent resumes only when the human replies.
|
|
389
|
+
console.log(s.markState('asking', { note: flag('note'), sessionId: sess }) ? `asking${DECLARED}` : 'no session record')
|
|
390
|
+
} else if (sub === 'commit-gate') {
|
|
391
|
+
// the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
|
|
392
|
+
// ready to declare done/merge (work committed + ahead of main), else print the reason and exit 1. Uses
|
|
393
|
+
// git() so the hook's exported GIT_DIR/GIT_INDEX_FILE don't misdirect repo discovery (see git.ts).
|
|
394
|
+
const r = s.mergeReadiness()
|
|
395
|
+
if (r.ready) { console.log('ready'); process.exit(0) }
|
|
396
|
+
console.log(r.reason)
|
|
397
|
+
process.exit(1)
|
|
398
|
+
} else if (sub === 'idle') {
|
|
399
|
+
// the Notification(idle_prompt) hook marks its session (--session from the payload) idle when claude waits
|
|
400
|
+
// at its prompt. INFERRED, so guarded active-only: it no-ops unless the current status is exactly `active`,
|
|
401
|
+
// never clobbering a deliberate awaiting/asking/parked/error declaration. Distinct from `ask`
|
|
402
|
+
// (the agent deliberately asking the human) — idle is the undeclared stop the Stop gate missed.
|
|
403
|
+
console.log(s.markIdle(sess) ? 'idle' : 'noop (no session record, or not active)')
|
|
404
|
+
} else if (sub === 'merge') {
|
|
405
|
+
// merge dispatch (same as top-level `spex merge`): reopen the session and hand its OWN agent the merge
|
|
406
|
+
// prompt — the agent runs the --no-ff merge, resolves conflicts, verifies main advanced, and proposes
|
|
407
|
+
// close. The SERVER never touches main. Fail-loud: an unreachable agent prints the reason, exits non-zero.
|
|
408
|
+
const full = await resolveSelectorOrExit(id)
|
|
409
|
+
const r = await c.clientMerge(full)
|
|
410
|
+
if (r.dispatched) console.log(`merge dispatched to ${full} — its agent is landing the merge`)
|
|
411
|
+
else console.error(`merge dispatch failed: ${r.reason}`)
|
|
412
|
+
process.exit(r.dispatched ? 0 : 1)
|
|
413
|
+
} else if (sub === 'exit') {
|
|
414
|
+
// the SOFT stop: kill the agent's tmux + socket but KEEP the worktree, so the session goes offline and
|
|
415
|
+
// can be resumed (reopen/relaunch). Distinct from `close`, which removes the worktree.
|
|
416
|
+
const full = await resolveSelectorOrExit(id)
|
|
417
|
+
console.log(await c.clientExit(full) ? `exited ${full} (worktree kept — resumable)` : `no such session ${full}`)
|
|
418
|
+
} else if (sub === 'close') {
|
|
419
|
+
const full = await resolveSelectorOrExit(id)
|
|
420
|
+
console.log(await c.clientClose(full) ? `closed ${full}` : `no such session ${full}`)
|
|
421
|
+
} else if (sub === 'send') {
|
|
422
|
+
// prompt dispatch is socket-only + fail-loud (the backend enforces it): a non-accepted prompt prints the
|
|
423
|
+
// reason AND exits non-zero, so a manager/script never mistakes a dead dispatch for success.
|
|
424
|
+
const full = await resolveSelectorOrExit(id)
|
|
425
|
+
// BIDIRECTIONAL: stamp the SENDER (this send process's OWN session — the only process that knows it, via
|
|
426
|
+
// ownSessionId from CLAUDE_CODE_SESSION_ID) + a one-line reply hint into the delivered
|
|
427
|
+
// message, so the recipient can reply over the SAME send. The sender's row (hence its display label) is
|
|
428
|
+
// resolved through the shared resolver; a human in a plain shell has no session id → bare message, no
|
|
429
|
+
// hint (see [[agent-reply-channel]]).
|
|
430
|
+
const senderId = s.ownSessionId()
|
|
431
|
+
let sender = null
|
|
432
|
+
if (senderId) {
|
|
433
|
+
const sr = await c.resolveClientSession(senderId)
|
|
434
|
+
// name the sender by the board HEADLINE (sessionHeadline — the live self-summary the recipient sees on
|
|
435
|
+
// the board), NOT the stable sessionLabel that stops at the bare prompt-truncation title.
|
|
436
|
+
sender = 'ok' in sr ? { id: sr.ok.id, label: s.sessionHeadline(sr.ok) } : { id: senderId, label: null }
|
|
437
|
+
}
|
|
438
|
+
const r = await c.clientSend(full, s.withSenderHint(process.argv[5] ?? '', sender), senderId ?? undefined)
|
|
439
|
+
console.log(r.ok ? 'sent' : `dispatch failed: ${r.error}`)
|
|
440
|
+
process.exit(r.ok ? 0 : 1)
|
|
441
|
+
} else if (sub === 'capture') {
|
|
442
|
+
// the session's live pane (output) over HTTP — fail and empty stay DISTINCT: a real empty pane prints
|
|
443
|
+
// nothing and exits 0; unknown id / offline / capture-error each exit non-zero with a named reason.
|
|
444
|
+
const full = await resolveSelectorOrExit(id)
|
|
445
|
+
const r = await c.clientCapture(full)
|
|
446
|
+
if (r.ok) { process.stdout.write(r.pane) }
|
|
447
|
+
else { console.error(`spex capture: ${r.reason}`); process.exit(r.status === 404 ? 2 : 1) }
|
|
448
|
+
} else if (sub === 'prompt') {
|
|
449
|
+
// print the session's full ORIGINATING prompt (what it was asked to do), captured at launch.
|
|
450
|
+
const full = await resolveSelectorOrExit(id)
|
|
451
|
+
const r = await c.clientPrompt(full)
|
|
452
|
+
if (!r.ok) { console.error(`no prompt recorded for ${full}`); process.exit(1) }
|
|
453
|
+
process.stdout.write(r.prompt.endsWith('\n') ? r.prompt : r.prompt + '\n')
|
|
454
|
+
} else {
|
|
455
|
+
console.error('spex session: new|list|reopen|review|done|park|ask|idle|merge|exit|close|send|capture|prompt'); process.exit(2)
|
|
456
|
+
}
|
|
457
|
+
} else if (cmd === 'codex-launch') {
|
|
458
|
+
// BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
|
|
459
|
+
// (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
|
|
460
|
+
// SPEXCODE_SESSION_ID), fire the launch prompt as the FIRST turn — materializing the rollout — and print the
|
|
461
|
+
// thread id. The launch script then `resume`s it in the visible TUI.
|
|
462
|
+
const { codexStartThread, codexTurn } = await import('./harness.js')
|
|
463
|
+
const { markHarnessSessionId } = await import('./sessions.js')
|
|
464
|
+
const sock = process.argv[3], cwd = process.argv[4]
|
|
465
|
+
const prompt = process.argv.slice(5).join(' ')
|
|
466
|
+
if (!sock || !cwd) { console.error('usage: spex codex-launch <sock> <cwd> [prompt...]'); process.exit(2) }
|
|
467
|
+
const r = await codexStartThread(sock, cwd)
|
|
468
|
+
if (!r.ok) { console.error(r.error); process.exit(1) }
|
|
469
|
+
const sid = process.env.SPEXCODE_SESSION_ID
|
|
470
|
+
if (sid) markHarnessSessionId(sid, r.threadId)
|
|
471
|
+
if (prompt) {
|
|
472
|
+
const t = await codexTurn(sock, r.threadId, prompt, cwd)
|
|
473
|
+
if (!t.ok) { console.error(t.error); process.exit(1) }
|
|
474
|
+
}
|
|
475
|
+
console.log(r.threadId)
|
|
476
|
+
} else if (cmd === 'codex-turn') {
|
|
477
|
+
// fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
|
|
478
|
+
// tests / scripts). steer-vs-start is chosen live from the thread read.
|
|
479
|
+
const { codexTurn } = await import('./harness.js')
|
|
480
|
+
const sock = process.argv[3], tid = process.argv[4], text = process.argv.slice(5).join(' ')
|
|
481
|
+
if (!sock || !tid || !text) { console.error('usage: spex codex-turn <sock> <threadId> <text...>'); process.exit(2) }
|
|
482
|
+
const r = await codexTurn(sock, tid, text)
|
|
483
|
+
if (r.ok) { console.log('ok') } else { console.error(r.error); process.exit(1) }
|
|
484
|
+
} else {
|
|
485
|
+
console.error(`spex: unknown command '${cmd}' (try: spex help)`)
|
|
486
|
+
process.exit(2)
|
|
487
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { apiBase, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
|
|
2
|
+
|
|
3
|
+
export class BackendError extends Error {
|
|
4
|
+
constructor(message: string, readonly status?: number) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = 'BackendError' // sessions.ts's isBackendDown matches on this name (no runtime import cycle)
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// the ONE seam where "no backend" becomes loud. A network failure (nothing listening at apiBase) is the only
|
|
11
|
+
// thing thrown; an HTTP Response of any status is returned for the caller to interpret.
|
|
12
|
+
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
13
|
+
try {
|
|
14
|
+
return await fetch(`${apiBase()}${path}`, init)
|
|
15
|
+
} catch (e) {
|
|
16
|
+
throw new BackendError(`no backend reachable at ${apiBase()} — run \`spex serve\` there, or set SPEXCODE_API_URL (${(e as Error).message})`)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const post = (body: unknown): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
20
|
+
const seg = (id: string) => encodeURIComponent(id)
|
|
21
|
+
|
|
22
|
+
// GET /api/sessions — the board, used by `spex ls`, and by `spex watch`/`wait` as their poll source.
|
|
23
|
+
export async function clientListSessions(): Promise<Session[]> {
|
|
24
|
+
const r = await apiFetch('/api/sessions')
|
|
25
|
+
if (!r.ok) throw new BackendError(`backend error ${r.status} listing sessions`, r.status)
|
|
26
|
+
return await r.json() as Session[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// resolve a selector (full id, id-prefix, node, or branch) against the live board, then call with the full id.
|
|
30
|
+
export async function resolveClientSession(selector: string): Promise<Resolved> {
|
|
31
|
+
return resolveSession(selector, await clientListSessions())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// GET /api/sessions/:id/capture — the live pane as text. The discriminated result keeps the three failure
|
|
35
|
+
// modes (404 unknown / 409 offline / 502 capture-failed) distinct from a legitimately empty pane (200, '').
|
|
36
|
+
export type CaptureResult = { ok: true; pane: string } | { ok: false; status: number; reason: string }
|
|
37
|
+
export async function clientCapture(id: string): Promise<CaptureResult> {
|
|
38
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/capture`)
|
|
39
|
+
if (r.ok) return { ok: true, pane: await r.text() }
|
|
40
|
+
return { ok: false, status: r.status, reason: (await r.text().catch(() => '')) || `status ${r.status}` }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// POST /api/sessions/:id/keys — prompt dispatch (the backend routes it through the rendezvous socket,
|
|
44
|
+
// socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
|
|
45
|
+
export async function clientSend(id: string, text: string, from?: string): Promise<DispatchResult> {
|
|
46
|
+
// `from` = the sending agent's own session id; the backend logs the comms edge ([[comms-edge]]) only when
|
|
47
|
+
// it's present (an agent send), so a human-shell send stays unrecorded.
|
|
48
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/keys`, post({ text, ...(from ? { from } : {}) }))
|
|
49
|
+
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// GET /api/sessions/:id/review — the manager cockpit review bundle (null on 404).
|
|
53
|
+
export async function clientReview(id: string): Promise<ReviewPayload | null> {
|
|
54
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/review`)
|
|
55
|
+
if (r.status === 404) return null
|
|
56
|
+
if (!r.ok) throw new BackendError(`backend error ${r.status} reviewing ${id}`, r.status)
|
|
57
|
+
return await r.json() as ReviewPayload
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// GET /api/sessions/:id/proof — the rendered review PROOF ([[review-proof]]): the self-contained HTML the
|
|
61
|
+
// backend builds (default), or the model JSON (`json:true` → ?format=json). The engine runs on the backend,
|
|
62
|
+
// so the CLI is a thin fetcher that writes/opens these bytes — works against a remote backend unchanged.
|
|
63
|
+
// 404 → no such session.
|
|
64
|
+
export type ProofResult = { ok: true; body: string } | { ok: false; status: number }
|
|
65
|
+
export async function clientProof(id: string, json = false): Promise<ProofResult> {
|
|
66
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/proof${json ? '?format=json' : ''}`)
|
|
67
|
+
if (r.ok) return { ok: true, body: await r.text() }
|
|
68
|
+
return { ok: false, status: r.status }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// POST /api/sessions/:id/merge — the cockpit's merge DISPATCH (200 {dispatched:true} / 409 {reason}).
|
|
72
|
+
export async function clientMerge(id: string): Promise<{ dispatched: boolean; reason?: string }> {
|
|
73
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/merge`, post({}))
|
|
74
|
+
return await r.json().catch(() => ({ dispatched: false, reason: `bad backend response (${r.status})` }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// POST /api/sessions/:id/resume — relaunch/back-to-working. {ok:false} = no such session.
|
|
78
|
+
export async function clientReopen(id: string): Promise<boolean> {
|
|
79
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({}))
|
|
80
|
+
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// POST /api/sessions/:id/exit — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,
|
|
84
|
+
// resumable). Distinct from close. {ok:false} = no such session.
|
|
85
|
+
export async function clientExit(id: string): Promise<boolean> {
|
|
86
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/exit`, post({}))
|
|
87
|
+
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// POST /api/sessions/:id/close — the human-only worktree removal. {ok:false} = no such session.
|
|
91
|
+
export async function clientClose(id: string): Promise<boolean> {
|
|
92
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/close`, post({}))
|
|
93
|
+
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// GET /api/sessions/:id/prompt — the session's originating prompt (404 if none recorded).
|
|
97
|
+
export type PromptResult = { ok: true; prompt: string } | { ok: false; status: number }
|
|
98
|
+
export async function clientPrompt(id: string): Promise<PromptResult> {
|
|
99
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/prompt`)
|
|
100
|
+
if (r.ok) return { ok: true, prompt: await r.text() }
|
|
101
|
+
return { ok: false, status: r.status }
|
|
102
|
+
}
|