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.
Files changed (88) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. package/spec-eval/src/matrix.ts +0 -693
@@ -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
- const greeted = new Set<string>()
165
- // Starting a STREAM follow on named live sessions announces itself ([[session-follow]]): one appended line per
166
- // target per process, so a stream never re-nags and a one-shot `wait` never announces at all. The append is the
167
- // delivery, taken locally, so the announce needs no backend either.
168
- async function greetWatchTargets(watcher: string, selectors: string[]): Promise<void> {
169
- try {
170
- const real = selectors.filter((sel) => sel && sel !== '@all')
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 resolveSelectorOrExit(selector: string): Promise<string> {
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 { sessionLabel } = await import('./sessions.js')
221
+ const { sessionTitle } = await import('./sessions.js')
192
222
  const r = await resolveClientSession(selector)
193
- if ('ok' in r) return r.ok.id
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)} ${sessionLabel(s)}`)
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: spec, eval, settings, footprint. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
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: nothing tracks this file's drift.
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
- 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.`)
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 { specLint, pendingTouchesGoverned, DRIFT_GUIDANCE } = await import('./lint.js')
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 findings = await specLint(undefined, undefined, {
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', 'matrix', 'lint', 'ok', 'retract', 'clean'].includes(sub)) {
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 | matrix | lint | ok | retract | clean (spex help eval)`)
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 selectors = positionals(4)
652
- const kit = await followKit(selectors, 'spex session watch')
653
- if (kit.watcher) void greetWatchTargets(kit.watcher, selectors)
654
- await kit.follow((line) => console.log(line), {
655
- statuses: flag('status')?.split(','),
656
- includeIdle: has('idle'),
657
- as: flag('as'),
658
- intervalMs: (Number(flag('interval')) || 1) * 1000,
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 id = await resolveSelectorOrExit(first)
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 ${r.label} [${r.id}]`)
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 r = await clientMerge(id)
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 (has('keys')) {
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
- const keys = (flag('keys') ?? '').split(/\s+/).filter(Boolean)
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(process.argv[5] ?? '', sender), senderId ?? undefined)
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.label} [${x.id}]`)
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
- const [root, command] = process.argv.slice(4, 6)
928
- if (!root || !command) { console.error('usage: spex internal codex-generation-current <runtime-root> <codex-command>'); process.exit(2) }
929
- const { ensureCodexCurrentGeneration } = await import('./codex-runtime-generations.js')
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 endpoint = await ensureCodexCurrentGeneration(root, async (candidate) => {
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 quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
939
- 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)}`)
940
- } else if (sub === 'codex-generation-session') {
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)}`)