spexcode 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/cli.ts
CHANGED
|
@@ -57,7 +57,7 @@ function flushExit(code = 0): Promise<never> {
|
|
|
57
57
|
}
|
|
58
58
|
const has = (name: string) => process.argv.includes(`--${name}`)
|
|
59
59
|
// bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
|
|
60
|
-
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch'])
|
|
60
|
+
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch', '--to', '--name', '--base'])
|
|
61
61
|
function positionals(from: number): string[] {
|
|
62
62
|
const out: string[] = []
|
|
63
63
|
for (let i = from; i < process.argv.length; i++) {
|
|
@@ -81,6 +81,51 @@ function rejectUnknownFlags(command: string, from: number, allowed: readonly str
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
type SessionSendArgs =
|
|
85
|
+
| { selector: string; kind: 'text'; text: string }
|
|
86
|
+
| { selector: string; kind: 'keys'; keys: string[] }
|
|
87
|
+
|
|
88
|
+
function sessionSendUsage(detail: string, keys = false): never {
|
|
89
|
+
console.error(`spex session send: ${detail}`)
|
|
90
|
+
console.error(keys
|
|
91
|
+
? 'usage: spex session send <SEL> --keys "<keys>" (e.g. "Up Up Enter", "C-r", single chars — last resort; try a plain send first)'
|
|
92
|
+
: 'usage: spex session send <SEL> "<msg>" [--api <url> | --port <n>]\n spex session send <SEL> [--api <url> | --port <n>] -- <option-shaped-msg>')
|
|
93
|
+
process.exit(2)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseSessionSendArgs(args: string[]): SessionSendArgs {
|
|
97
|
+
const valueFlags = new Set(['--api', '--port', '--keys', '--password'])
|
|
98
|
+
const bareFlags = new Set(['--insecure'])
|
|
99
|
+
const values = new Map<string, string>()
|
|
100
|
+
const positionals: string[] = []
|
|
101
|
+
let endOfOptions = false
|
|
102
|
+
for (let i = 0; i < args.length; i++) {
|
|
103
|
+
const token = args[i]
|
|
104
|
+
if (!endOfOptions && token === '--') { endOfOptions = true; continue }
|
|
105
|
+
if (!endOfOptions && token.startsWith('--')) {
|
|
106
|
+
if (!valueFlags.has(token) && !bareFlags.has(token)) sessionSendUsage(`unknown flag ${token}`)
|
|
107
|
+
if (values.has(token)) sessionSendUsage(`${token} may appear only once`, token === '--keys')
|
|
108
|
+
values.set(token, '')
|
|
109
|
+
if (bareFlags.has(token)) continue
|
|
110
|
+
const value = args[++i]
|
|
111
|
+
if (value === undefined || value === '' || value.startsWith('--')) sessionSendUsage(`${token} expects one non-empty value`, token === '--keys')
|
|
112
|
+
values.set(token, value)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
positionals.push(token)
|
|
116
|
+
}
|
|
117
|
+
if (values.has('--api') && values.has('--port')) sessionSendUsage('--api and --port are alternate routes; choose one')
|
|
118
|
+
const rawKeys = values.get('--keys')
|
|
119
|
+
if (rawKeys !== undefined) {
|
|
120
|
+
if (positionals.length !== 1) sessionSendUsage('raw keys take one selector and no text message', true)
|
|
121
|
+
const keys = rawKeys.split(/\s+/).filter(Boolean)
|
|
122
|
+
if (!keys.length) sessionSendUsage('--keys expects one non-empty value', true)
|
|
123
|
+
return { selector: positionals[0], kind: 'keys', keys }
|
|
124
|
+
}
|
|
125
|
+
if (positionals.length !== 2) sessionSendUsage('plain send requires exactly one selector and one message')
|
|
126
|
+
return { selector: positionals[0], kind: 'text', text: positionals[1] }
|
|
127
|
+
}
|
|
128
|
+
|
|
84
129
|
const SIGNPOSTS: Record<string, string> = {
|
|
85
130
|
search: 'spex spec search <query>',
|
|
86
131
|
owner: 'spex spec owner <path>',
|
|
@@ -161,42 +206,31 @@ async function followKit(selectors: string[], verb: string): Promise<{
|
|
|
161
206
|
return { watcher, follow: (emit, opts) => followSessions(emit, { ...opts, targets, self: watcher, row }) }
|
|
162
207
|
}
|
|
163
208
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (!real.length) return
|
|
172
|
-
const { localCachedSessions } = await import('./client.js')
|
|
173
|
-
const { selectSessions, sendText, sessionHeadline } = await import('./sessions.js')
|
|
174
|
-
const board = localCachedSessions(true)
|
|
175
|
-
// name the watcher by its board HEADLINE (same as the reply-channel footer), delimited as a session title.
|
|
176
|
-
const mine = board.find((s) => s.id === watcher)
|
|
177
|
-
const me = mine ? sessionHeadline(mine) : watcher
|
|
178
|
-
const meWho = me && me !== watcher ? `session "${me}" (${watcher})` : `session ${watcher}`
|
|
179
|
-
for (const target of selectSessions(board, real).map((s) => s.id)) {
|
|
180
|
-
if (target === watcher || greeted.has(target)) continue
|
|
181
|
-
greeted.add(target)
|
|
182
|
-
const text = `🔭 ${meWho} is now supervising you — they started \`spex session 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.)`
|
|
183
|
-
void sendText(target, text) // no sender id → the connection notice is not double-counted as comms
|
|
184
|
-
}
|
|
185
|
-
} catch { /* greeting is best-effort — it must never disturb the watch */ }
|
|
209
|
+
async function localWatchTargetsOrExit(selectors: string[], verb: string): Promise<string[]> {
|
|
210
|
+
if (!selectors.length) { console.error(`usage: ${verb} <SEL...>`); process.exit(2) }
|
|
211
|
+
const { localCachedSessions } = await import('./client.js')
|
|
212
|
+
const { selectSessions } = await import('./sessions.js')
|
|
213
|
+
const targets = selectSessions(localCachedSessions(true), selectors).map((session) => session.id)
|
|
214
|
+
if (!targets.length) { console.error(`${verb}: no such local session: ${selectors.join(' ')}`); process.exit(2) }
|
|
215
|
+
return targets
|
|
186
216
|
}
|
|
187
217
|
|
|
188
|
-
async function
|
|
218
|
+
async function resolveSessionOrExit(selector: string): Promise<import('./sessions.js').Session> {
|
|
189
219
|
if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch | . for self)'); process.exit(2) }
|
|
190
220
|
const { resolveClientSession } = await import('./client.js')
|
|
191
|
-
const {
|
|
221
|
+
const { sessionTitle } = await import('./sessions.js')
|
|
192
222
|
const r = await resolveClientSession(selector)
|
|
193
|
-
if ('ok' in r) return r.ok
|
|
223
|
+
if ('ok' in r) return r.ok
|
|
194
224
|
if ('none' in r) { console.error(`spex: no such session: ${selector}`); process.exit(2) }
|
|
195
225
|
console.error(`spex: ambiguous selector "${selector}" matches ${r.ambiguous.length} sessions — be more specific:`)
|
|
196
|
-
for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${
|
|
226
|
+
for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${sessionTitle(s)}`)
|
|
197
227
|
process.exit(2)
|
|
198
228
|
}
|
|
199
229
|
|
|
230
|
+
async function resolveSelectorOrExit(selector: string): Promise<string> {
|
|
231
|
+
return (await resolveSessionOrExit(selector)).id
|
|
232
|
+
}
|
|
233
|
+
|
|
200
234
|
// the [[session-eval]] EXPORT artifact behind `spex eval ls --session <SEL> --export`: fetch the
|
|
201
235
|
// backend-rendered self-contained HTML, write it (--out, else a tmp file) or open it (--open). Never returns.
|
|
202
236
|
async function evalExport(id: string): Promise<never> {
|
|
@@ -352,11 +386,11 @@ if (cmd === 'serve') {
|
|
|
352
386
|
console.log(h)
|
|
353
387
|
} else console.log(overviewHelp())
|
|
354
388
|
} else if (cmd === 'guide') {
|
|
355
|
-
const { guideText } = await import('./guide.js')
|
|
389
|
+
const { guideText, guideTopics } = await import('./guide.js')
|
|
356
390
|
if (process.argv[3] === 'config') signpost('spex guide config', 'spex guide settings')
|
|
357
391
|
const text = guideText(process.argv[3])
|
|
358
392
|
if (text === null) {
|
|
359
|
-
console.error(`spex guide: no topic '${process.argv[3]}'. Topics:
|
|
393
|
+
console.error(`spex guide: no topic '${process.argv[3]}'. Topics: ${guideTopics().join(', ')}. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
|
|
360
394
|
process.exit(2)
|
|
361
395
|
}
|
|
362
396
|
console.log(text)
|
|
@@ -440,9 +474,25 @@ if (cmd === 'serve') {
|
|
|
440
474
|
console.log(`${rel} — no spec claims this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
|
|
441
475
|
} else if (owners.length === 0) {
|
|
442
476
|
// related-only: lint's coverage is satisfied, so the per-edit hook stays silent (lint-consistent) —
|
|
443
|
-
// but a human asking gets the honest nuance
|
|
477
|
+
// but a human asking gets the honest nuance about what does and does not track this file.
|
|
444
478
|
if (has('actionable')) process.exit(0)
|
|
445
|
-
|
|
479
|
+
// @@@ both axes - "nothing tracks its drift" is a verdict about the WHOLE tracking model
|
|
480
|
+
// ([[governed-related]]: spec nodes AND eval scenarios), so it cannot be spoken from the spec axis
|
|
481
|
+
// alone — a scenario's code: anchor drives eval freshness with no spec claim involved. Derived, never
|
|
482
|
+
// asserted: a message that enumerates one axis cannot report what the other holds. The empty
|
|
483
|
+
// nodeCode fallback is exact HERE and only here — reaching this branch means no spec node code:-claims
|
|
484
|
+
// the file, so no scenario can anchor to it by inheriting its node's claim, only by an explicit one.
|
|
485
|
+
const { evalNodes, scenarioCodeAxis } = await import('../../spec-eval/src/scenarios.js')
|
|
486
|
+
const anchored: string[] = []
|
|
487
|
+
for (const n of evalNodes(process.cwd())) {
|
|
488
|
+
for (const sc of n.scenarios) {
|
|
489
|
+
if (scenarioCodeAxis(sc.code).paths.includes(rel)) anchored.push(`'${n.id}' scenario '${sc.name}'`)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const tracking = anchored.length
|
|
493
|
+
? `Its drift is tracked on the eval axis only: ${anchored.length} scenario${anchored.length === 1 ? '' : 's'} anchor${anchored.length === 1 ? 's' : ''} freshness to it (${anchored.join(', ')}), so changing it makes those readings stale — but no spec body says what it should do`
|
|
494
|
+
: 'Nothing tracks its drift'
|
|
495
|
+
console.log(`${rel} — not governed (no code: claim), but referenced by ${names(related)} (related: coverage only). ${tracking}; if your change is substantive, consider giving it a governing home.`)
|
|
446
496
|
} else if (whole.length <= maxOwners) {
|
|
447
497
|
// a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
|
|
448
498
|
// silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
|
|
@@ -455,16 +505,21 @@ if (cmd === 'serve') {
|
|
|
455
505
|
console.log(`${rel} is governed whole-file by ${whole.length} specs (all claims: ${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}`)
|
|
456
506
|
}
|
|
457
507
|
} else if (sub === 'lint') {
|
|
458
|
-
const {
|
|
508
|
+
const { specLintReport, pendingTouchesGoverned, DRIFT_GUIDANCE } = await import('./lint.js')
|
|
459
509
|
const pending = flag('pending')
|
|
460
510
|
if (pending && process.env.SPEXCODE_GATE_SCOPE_ONLY === '1') {
|
|
461
511
|
const touches = await pendingTouchesGoverned(process.cwd(), pending)
|
|
462
512
|
if (!touches) process.exit(76)
|
|
463
513
|
}
|
|
464
|
-
const
|
|
514
|
+
const report = await specLintReport(undefined, undefined, {
|
|
465
515
|
tip: pending || 'HEAD',
|
|
466
516
|
})
|
|
517
|
+
const { findings } = report
|
|
467
518
|
const errors = findings.filter((f) => f.level === 'error')
|
|
519
|
+
if (has('json')) {
|
|
520
|
+
console.log(JSON.stringify(report, null, 2))
|
|
521
|
+
await flushExit(errors.length ? 1 : 0)
|
|
522
|
+
}
|
|
468
523
|
for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
|
|
469
524
|
console.error(`spex spec lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
|
|
470
525
|
// drift teaches from the ONE `spex spec lint` (no flag). Unanchored drift stays advisory forever; the
|
|
@@ -528,7 +583,7 @@ if (cmd === 'serve') {
|
|
|
528
583
|
const m = r.model
|
|
529
584
|
const own = m.items.filter((item) => item.inSession).length
|
|
530
585
|
console.log(`eval session [${m.id}]`)
|
|
531
|
-
console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
|
|
586
|
+
if (m.gates.length) console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
|
|
532
587
|
if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = evals filed by other sessions)`)
|
|
533
588
|
if (!m.items.length) console.log('\n no affected scenarios to evaluate yet')
|
|
534
589
|
let lastNode: string | null = null
|
|
@@ -545,12 +600,12 @@ if (cmd === 'serve') {
|
|
|
545
600
|
const stale = e.fresh ? '' : ` (stale: ${(e.staleAxes || []).join(',')})`
|
|
546
601
|
console.log(` ${e.inSession ? '✦' : ' '} ${verdict}${stale} ${e.scenario} — ${e.ts}${e.evaluator ? ` · ${e.evaluator}` : ''}`)
|
|
547
602
|
}
|
|
548
|
-
} else if (['add', 'ls', 'scenario', '
|
|
603
|
+
} else if (['add', 'ls', 'scenario', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
|
|
549
604
|
// node-scoped verbs — thin route; the logic lives in spec-eval.
|
|
550
605
|
const { runEval } = await import('../../spec-eval/src/cli.js')
|
|
551
606
|
await flushExit(await runEval(process.argv.slice(3)))
|
|
552
607
|
} else {
|
|
553
|
-
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls/write |
|
|
608
|
+
console.error(`spex eval: unknown verb '${sub}' — add | ls | scenario ls/write | lint | ok | retract | clean (spex help eval)`)
|
|
554
609
|
if (!sub.startsWith('--')) console.error(` (the old \`spex eval <SEL>\` session read is now \`spex eval ls --session <SEL>\` [--export])`) // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
|
|
555
610
|
process.exit(2)
|
|
556
611
|
}
|
|
@@ -612,7 +667,7 @@ if (cmd === 'serve') {
|
|
|
612
667
|
console.error('spex session new: --node was removed — put a [[<id>]] mention in the prompt — the first mention binds')
|
|
613
668
|
process.exit(2)
|
|
614
669
|
}
|
|
615
|
-
rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'api', 'port'])
|
|
670
|
+
rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'name', 'base', 'api', 'port'])
|
|
616
671
|
const { createSession } = await import('./sessions.js')
|
|
617
672
|
const promptFile = flag('prompt-file')
|
|
618
673
|
const inline = flag('prompt') ?? positionals(4)[0]
|
|
@@ -625,9 +680,19 @@ if (cmd === 'serve') {
|
|
|
625
680
|
catch (e) { console.error(`spex session new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
|
|
626
681
|
if (!prompt.trim()) { console.error(`spex session new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
|
|
627
682
|
}
|
|
628
|
-
const created = await createSession(prompt, flag('launcher') ?? undefined)
|
|
683
|
+
const created = await createSession(prompt, flag('launcher') ?? undefined, flag('name') ?? undefined, flag('base') ?? undefined)
|
|
684
|
+
const { ownSessionId, subscribeSessionWatch } = await import('./sessions.js')
|
|
685
|
+
let watchEstablished = false
|
|
686
|
+
if (created.parent && created.parent === ownSessionId()) {
|
|
687
|
+
try {
|
|
688
|
+
await subscribeSessionWatch(created.parent, [created.id])
|
|
689
|
+
watchEstablished = true
|
|
690
|
+
} catch (error) {
|
|
691
|
+
console.error(`spex session new: child ${created.id} was created, but its managed watch was not established: ${error instanceof Error ? error.message : String(error)}`)
|
|
692
|
+
}
|
|
693
|
+
}
|
|
629
694
|
console.log(JSON.stringify(created, null, 2))
|
|
630
|
-
console.error((await import('./help.js')).sessionLaunchReceipt(created.id))
|
|
695
|
+
console.error((await import('./help.js')).sessionLaunchReceipt(created.id, watchEstablished))
|
|
631
696
|
} else if (sub === 'ls') {
|
|
632
697
|
// pretty list of living sessions + states. `spex session ls [SEL...] [--status a,b] [--json]`
|
|
633
698
|
// the board comes from the backend (so it shows the sessions of whatever SPEXCODE_API_URL points at,
|
|
@@ -647,16 +712,90 @@ if (cmd === 'serve') {
|
|
|
647
712
|
const report = await clientResources()
|
|
648
713
|
if (has('json')) console.log(JSON.stringify(report, null, 2))
|
|
649
714
|
else console.log((await import('./host-resources.js')).formatResourceReport(report))
|
|
715
|
+
} else if (sub === 'files') {
|
|
716
|
+
rejectUnknownFlags('spex session files', 4, [])
|
|
717
|
+
const [verb, path, extra] = positionals(4)
|
|
718
|
+
if (extra || !verb || (verb !== 'ls' && !path) || (verb === 'ls' && path)) {
|
|
719
|
+
console.error('usage: spex session files add <path> | ls | retract <path>')
|
|
720
|
+
process.exit(2)
|
|
721
|
+
}
|
|
722
|
+
const { ownSessionId, withSessionRecordLockSync } = await import('./sessions.js')
|
|
723
|
+
const id = ownSessionId()
|
|
724
|
+
if (!id) {
|
|
725
|
+
console.error('spex session files: no governed caller session — run this from the agent session that produced the file')
|
|
726
|
+
process.exit(2)
|
|
727
|
+
}
|
|
728
|
+
const files = await import('./session-files.js')
|
|
729
|
+
if (verb === 'ls') {
|
|
730
|
+
for (const file of files.listSessionFiles(id)) console.log(file)
|
|
731
|
+
} else if (verb === 'add') {
|
|
732
|
+
const result = files.addSessionFile(id, path!, withSessionRecordLockSync)
|
|
733
|
+
console.log(result.added ? `posted ${result.path}` : `already posted ${result.path}`)
|
|
734
|
+
} else if (verb === 'retract') {
|
|
735
|
+
const result = files.retractSessionFile(id, path!, withSessionRecordLockSync)
|
|
736
|
+
if (!result.removed) { console.error(`spex session files retract: path is not posted: ${result.path}`); process.exit(2) }
|
|
737
|
+
console.log(`retracted ${result.path}`)
|
|
738
|
+
} else {
|
|
739
|
+
console.error(`spex session files: unknown verb '${verb}' — add | ls | retract (spex help session)`)
|
|
740
|
+
process.exit(2)
|
|
741
|
+
}
|
|
742
|
+
} else if (sub === 'web') {
|
|
743
|
+
rejectUnknownFlags('spex session web', 4, [])
|
|
744
|
+
const [verb, url, extra] = positionals(4)
|
|
745
|
+
if (extra || !verb || (verb !== 'ls' && !url) || (verb === 'ls' && url)) {
|
|
746
|
+
console.error('usage: spex session web add <url> | ls | retract <url>')
|
|
747
|
+
process.exit(2)
|
|
748
|
+
}
|
|
749
|
+
const { ownSessionId, withSessionRecordLockSync } = await import('./sessions.js')
|
|
750
|
+
const id = ownSessionId()
|
|
751
|
+
if (!id) {
|
|
752
|
+
console.error('spex session web: no governed caller session — run this from the agent session that started the local service')
|
|
753
|
+
process.exit(2)
|
|
754
|
+
}
|
|
755
|
+
const web = await import('./session-web.js')
|
|
756
|
+
if (verb === 'ls') {
|
|
757
|
+
for (const entry of web.listSessionWebs(id)) console.log(entry.url)
|
|
758
|
+
} else if (verb === 'add') {
|
|
759
|
+
const result = web.addSessionWeb(id, url!, withSessionRecordLockSync)
|
|
760
|
+
console.log(result.added ? `posted ${result.url}` : `already posted ${result.url}`)
|
|
761
|
+
} else if (verb === 'retract') {
|
|
762
|
+
const result = web.retractSessionWeb(id, url!, withSessionRecordLockSync)
|
|
763
|
+
if (!result.removed) { console.error(`spex session web retract: URL is not posted: ${result.url}`); process.exit(2) }
|
|
764
|
+
console.log(`retracted ${result.url}`)
|
|
765
|
+
} else {
|
|
766
|
+
console.error(`spex session web: unknown verb '${verb}' — add | ls | retract (spex help session)`)
|
|
767
|
+
process.exit(2)
|
|
768
|
+
}
|
|
650
769
|
} else if (sub === 'watch') {
|
|
651
|
-
const
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
770
|
+
const [verb, ...rest] = positionals(4)
|
|
771
|
+
const { ownSessionId, subscribeSessionWatch, listSessionWatches, cancelSessionWatch } = await import('./sessions.js')
|
|
772
|
+
const watcher = ownSessionId()
|
|
773
|
+
if (verb === 'stream') {
|
|
774
|
+
const kit = await followKit(rest, 'spex session watch stream')
|
|
775
|
+
await kit.follow((line) => console.log(line), {
|
|
776
|
+
statuses: flag('status')?.split(','),
|
|
777
|
+
includeIdle: has('idle'),
|
|
778
|
+
as: flag('as'),
|
|
779
|
+
intervalMs: (Number(flag('interval')) || 1) * 1000,
|
|
780
|
+
})
|
|
781
|
+
} else if (verb === 'list') {
|
|
782
|
+
if (!watcher) { console.error('spex session watch list: no governed caller session — this caller has no durable watch relation'); process.exit(2) }
|
|
783
|
+
for (const watch of listSessionWatches(watcher)) console.log(`${watch.target}\t${watch.createdAt}`)
|
|
784
|
+
} else if (verb === 'cancel') {
|
|
785
|
+
if (!watcher) { console.error(`spex session watch cancel: no governed caller session — run \`spex session wait ${rest.join(' ')}\` in the background instead`); process.exit(2) }
|
|
786
|
+
const targets = await localWatchTargetsOrExit(rest, 'spex session watch cancel')
|
|
787
|
+
console.log(`cancelled ${cancelSessionWatch(watcher, targets)} watch${targets.length === 1 ? '' : 'es'}`)
|
|
788
|
+
} else {
|
|
789
|
+
const selectors = [verb, ...rest].filter(Boolean)
|
|
790
|
+
if (!watcher) {
|
|
791
|
+
if (!selectors.length) { console.error('usage: spex session watch <SEL...>'); process.exit(2) }
|
|
792
|
+
console.error(`spex session watch: no governed caller session — run \`spex session wait ${selectors.join(' ')}\` in the BACKGROUND to receive the next actionable transition`)
|
|
793
|
+
} else {
|
|
794
|
+
const targets = await localWatchTargetsOrExit(selectors, 'spex session watch')
|
|
795
|
+
const result = await subscribeSessionWatch(watcher, targets)
|
|
796
|
+
console.log(`watching ${result.watched.join(' ')}`)
|
|
797
|
+
}
|
|
798
|
+
}
|
|
660
799
|
} else if (sub === 'wait') {
|
|
661
800
|
const selectors = positionals(4)
|
|
662
801
|
const kit = await followKit(selectors, 'spex session wait')
|
|
@@ -695,13 +834,13 @@ if (cmd === 'serve') {
|
|
|
695
834
|
if (first === 'proof') signpost('spex review proof', 'spex eval ls --session <SEL> --export') // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
|
|
696
835
|
const { clientReview } = await import('./client.js')
|
|
697
836
|
if (!first) { console.error('usage: spex session review <SEL> (id | id-prefix | node | branch)'); process.exit(2) }
|
|
698
|
-
const
|
|
699
|
-
const r = await clientReview(id)
|
|
700
|
-
if (!r) { console.error(`no such session ${id}`); process.exit(1) }
|
|
837
|
+
const session = await resolveSessionOrExit(first)
|
|
838
|
+
const r = await clientReview(session.id)
|
|
839
|
+
if (!r) { console.error(`no such session ${session.id}`); process.exit(1) }
|
|
701
840
|
if (has('json')) { console.log(JSON.stringify(r, null, 2)) }
|
|
702
841
|
else {
|
|
703
842
|
const g = r.gates
|
|
704
|
-
console.log(`review ${
|
|
843
|
+
console.log(`review ${session.title} [${r.id}]`)
|
|
705
844
|
console.log(` ahead of main : ${r.ahead} commit(s)`)
|
|
706
845
|
console.log(` uncommitted : ${r.dirtyNonRuntime} non-runtime file(s)`)
|
|
707
846
|
console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
|
|
@@ -717,11 +856,17 @@ if (cmd === 'serve') {
|
|
|
717
856
|
for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
|
|
718
857
|
}
|
|
719
858
|
} else if (sub === 'merge') {
|
|
720
|
-
const { clientMerge } = await import('./client.js')
|
|
859
|
+
const [{ clientMerge, clientReview }, { createHash }] = await Promise.all([import('./client.js'), import('node:crypto')])
|
|
721
860
|
const sel = positionals(4)[0]
|
|
722
861
|
if (!sel) { console.error('usage: spex session merge <SEL> (id | id-prefix | node | branch)'); process.exit(2) }
|
|
723
862
|
const id = await resolveSelectorOrExit(sel)
|
|
724
|
-
const
|
|
863
|
+
const review = await clientReview(id)
|
|
864
|
+
if (!review) { console.error(`merge dispatch failed: no such session ${id}`); process.exit(1) }
|
|
865
|
+
const r = await clientMerge(id, {
|
|
866
|
+
expectedBranchHead: review.branchHead,
|
|
867
|
+
expectedBaseHead: review.baseHead,
|
|
868
|
+
requestKey: createHash('sha256').update(`spexcode-cli-session-merge\0${id}\0${review.branchHead}\0${review.baseHead}`).digest('hex'),
|
|
869
|
+
})
|
|
725
870
|
if (r.dispatched) console.log(`merge dispatched to ${id} — its agent is landing the merge`)
|
|
726
871
|
else console.error(`merge dispatch failed: ${r.reason}`)
|
|
727
872
|
process.exit(r.dispatched ? 0 : 1)
|
|
@@ -729,8 +874,9 @@ if (cmd === 'serve') {
|
|
|
729
874
|
// `s` (sessions.ts) backs the state PRODUCERS that stay local (done/park/ask write the global record by
|
|
730
875
|
// session_id) and the stateKit shared with `spex internal session-*`. `c` (client.ts) backs the
|
|
731
876
|
// read/control subs that route through the backend. Lazily imported.
|
|
877
|
+
const sendArgs = sub === 'send' ? parseSessionSendArgs(process.argv.slice(4)) : null
|
|
732
878
|
const c = await import('./client.js')
|
|
733
|
-
const id = process.argv[4]
|
|
879
|
+
const id = sendArgs?.selector ?? process.argv[4]
|
|
734
880
|
if (sub === 'resume') {
|
|
735
881
|
// bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
|
|
736
882
|
// `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts resumeSession()).
|
|
@@ -807,18 +953,30 @@ if (cmd === 'serve') {
|
|
|
807
953
|
const quarantined = await c.clientQuarantine(id, { adapter, thread: flag('thread') ?? null, tmux, worktree, branch })
|
|
808
954
|
console.log(`quarantined ${quarantined.id} -> ${quarantined.bundle}`)
|
|
809
955
|
}
|
|
956
|
+
} else if (sub === 'reparent') {
|
|
957
|
+
rejectUnknownFlags('spex session reparent', 4, ['to', 'api', 'port'])
|
|
958
|
+
const children = positionals(4)
|
|
959
|
+
const to = flag('to')
|
|
960
|
+
if (!children.length || !to) {
|
|
961
|
+
console.error('usage: spex session reparent <child-SEL...> --to <parent-SEL>')
|
|
962
|
+
process.exit(2)
|
|
963
|
+
}
|
|
964
|
+
const childIds = [...new Set(await Promise.all(children.map(resolveSelectorOrExit)))]
|
|
965
|
+
const parent = await resolveSelectorOrExit(to)
|
|
966
|
+
const { reparentSessions } = await import('./session-reparent.js')
|
|
967
|
+
const result = await reparentSessions(childIds, parent)
|
|
968
|
+
console.log(`reparented ${result.children.join(', ')} -> ${result.parent}`)
|
|
810
969
|
} else if (sub === 'send') {
|
|
970
|
+
if (!sendArgs) sessionSendUsage('arguments were not parsed')
|
|
811
971
|
const full = await resolveSelectorOrExit(id)
|
|
812
|
-
if (
|
|
972
|
+
if (sendArgs.kind === 'keys') {
|
|
813
973
|
// the LAST-RESORT face of send: forward raw nav-mode keystrokes (tmux send-keys, NEVER the prompt
|
|
814
974
|
// socket) — how a manager drives a worker wedged in an interactive TUI dialog the prompt channel
|
|
815
975
|
// can't reach (a select menu wanting one Enter/arrow). UNSTABLE, and able to confirm dangerous
|
|
816
976
|
// dialogs — try plain `session send` text FIRST; reach for --keys only when text provably can't
|
|
817
977
|
// land. Tokens = named keys, single chars, C-/M-/S- combos; whitespace-separated, delivered as ONE
|
|
818
978
|
// ordered batch ([[nav-mode-key-ordering]]). Fail-loud: nothing delivered exits non-zero.
|
|
819
|
-
|
|
820
|
-
if (keys.length === 0) { console.error('usage: spex session send <SEL> --keys "<keys>" (e.g. "Up Up Enter", "C-r", single chars — last resort; try a plain send first)'); process.exit(2) }
|
|
821
|
-
if (await c.clientSendRawKeys(full, keys)) { console.log(`sent ${keys.length} key${keys.length === 1 ? '' : 's'} -> ${full}`); process.exit(0) }
|
|
979
|
+
if (await c.clientSendRawKeys(full, sendArgs.keys)) { console.log(`sent ${sendArgs.keys.length} key${sendArgs.keys.length === 1 ? '' : 's'} -> ${full}`); process.exit(0) }
|
|
822
980
|
console.error(`spex session send --keys: nothing delivered to ${full} (offline, unknown session, or no valid key token)`)
|
|
823
981
|
process.exit(1)
|
|
824
982
|
}
|
|
@@ -838,7 +996,7 @@ if (cmd === 'serve') {
|
|
|
838
996
|
// the board), NOT the stable sessionLabel that stops at the bare prompt-truncation title.
|
|
839
997
|
sender = 'ok' in sr ? { id: sr.ok.id, label: s.sessionHeadline(sr.ok) } : { id: senderId, label: null }
|
|
840
998
|
}
|
|
841
|
-
const r = await c.clientSend(full, s.withSenderHint(
|
|
999
|
+
const r = await c.clientSend(full, s.withSenderHint(sendArgs.text, sender), senderId ?? undefined)
|
|
842
1000
|
console.log(r.ok ? 'sent' : `dispatch failed: ${r.error}`)
|
|
843
1001
|
process.exit(r.ok ? 0 : 1)
|
|
844
1002
|
} else if (sub === 'show') {
|
|
@@ -857,7 +1015,7 @@ if (cmd === 'serve') {
|
|
|
857
1015
|
if (has('json')) { console.log(JSON.stringify(r.session, null, 2)) }
|
|
858
1016
|
else {
|
|
859
1017
|
const x = r.session
|
|
860
|
-
console.log(`${x.
|
|
1018
|
+
console.log(`${x.title} [${x.id}]`)
|
|
861
1019
|
console.log(` status : ${x.status} (lifecycle ${x.lifecycle} · liveness ${x.liveness})`)
|
|
862
1020
|
console.log(` node : ${x.node ?? '—'}`)
|
|
863
1021
|
console.log(` branch : ${x.branch ?? '—'}`)
|
|
@@ -886,7 +1044,7 @@ if (cmd === 'serve') {
|
|
|
886
1044
|
await assertLocalBackend()
|
|
887
1045
|
process.exit(await attachSession(await resolveSelectorOrExit(id)))
|
|
888
1046
|
} else {
|
|
889
|
-
console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
|
|
1047
|
+
console.error(`spex session: unknown verb '${sub}' — new | ls | files | web | show | watch | wait | review | merge | reparent | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
|
|
890
1048
|
process.exit(2)
|
|
891
1049
|
}
|
|
892
1050
|
}
|
|
@@ -923,25 +1081,31 @@ if (cmd === 'serve') {
|
|
|
923
1081
|
delete env.SPEXCODE_SESSION_ID
|
|
924
1082
|
const runtime = await spawnDetachedRuntime({ cwd, logFile, pidFile, receiptFile, command, args, env })
|
|
925
1083
|
console.log(runtime.pid)
|
|
926
|
-
} else if (sub === 'codex-generation-current') {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
const
|
|
1084
|
+
} else if (sub === 'codex-generation-current' || sub === 'codex-generation-session') {
|
|
1085
|
+
// Both launch spellings resolve ONE endpoint and print it as shell assignments. Starting a root is the same
|
|
1086
|
+
// detached spawn either way — a session identity must never ride into the shared, project-scoped daemon.
|
|
1087
|
+
const resume = sub === 'codex-generation-session'
|
|
1088
|
+
const [root, ...rest] = process.argv.slice(4)
|
|
1089
|
+
const [sessionId, threadId, sessionCommand] = rest
|
|
1090
|
+
const command = resume ? sessionCommand : rest[0]
|
|
1091
|
+
if (!root || !command || (resume && (!sessionId || !threadId))) {
|
|
1092
|
+
console.error(resume
|
|
1093
|
+
? 'usage: spex internal codex-generation-session <runtime-root> <session-id> <thread-id> <codex-command>'
|
|
1094
|
+
: 'usage: spex internal codex-generation-current <runtime-root> <codex-command>')
|
|
1095
|
+
process.exit(2)
|
|
1096
|
+
}
|
|
1097
|
+
const { ensureCodexCurrentGeneration, resolveCodexGenerationForResume } = await import('./codex-runtime-generations.js')
|
|
930
1098
|
const { spawnDetachedRuntime } = await import('./runtime-ownership.js')
|
|
931
1099
|
const { sessionIdentityEnvVars } = await import('./harness.js')
|
|
932
1100
|
const env = { ...process.env }
|
|
933
1101
|
for (const key of sessionIdentityEnvVars()) delete env[key]
|
|
934
|
-
const
|
|
1102
|
+
const start = async (candidate: { logFile: string; pidFile: string; receiptFile: string; socketPath: string }) => {
|
|
935
1103
|
await spawnDetachedRuntime({ cwd: root, logFile: candidate.logFile, pidFile: candidate.pidFile, receiptFile: candidate.receiptFile,
|
|
936
1104
|
command, args: ['app-server', '--listen', `unix://${candidate.socketPath}`], env })
|
|
937
|
-
}
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
const [root, sessionId, threadId] = process.argv.slice(4, 7)
|
|
942
|
-
if (!root || !sessionId || !threadId) { console.error('usage: spex internal codex-generation-session <runtime-root> <session-id> <thread-id>'); process.exit(2) }
|
|
943
|
-
const { resolveCodexGenerationForSession } = await import('./codex-runtime-generations.js')
|
|
944
|
-
const endpoint = resolveCodexGenerationForSession(root, sessionId, threadId)
|
|
1105
|
+
}
|
|
1106
|
+
const endpoint = resume
|
|
1107
|
+
? await resolveCodexGenerationForResume(root, sessionId!, threadId!, start)
|
|
1108
|
+
: await ensureCodexCurrentGeneration(root, start)
|
|
945
1109
|
if (!endpoint) { console.error(`no exact Codex generation binding for session ${sessionId} thread ${threadId}`); process.exit(1) }
|
|
946
1110
|
const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
|
|
947
1111
|
console.log(`sock=${quote(endpoint.socketPath)}; pid=${quote(endpoint.pidFile)}; receipt=${quote(endpoint.receiptFile)}; log=${quote(endpoint.logFile)}; export SPEXCODE_CODEX_GENERATION=${quote(endpoint.id)}`)
|