spexcode 0.1.6 → 0.2.1

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 (73) hide show
  1. package/README.md +99 -35
  2. package/README.zh-CN.md +135 -0
  3. package/package.json +5 -6
  4. package/spec-cli/README.md +86 -0
  5. package/spec-cli/bin/spex.mjs +15 -3
  6. package/spec-cli/hooks/dispatch.sh +20 -8
  7. package/spec-cli/hooks/harness.sh +18 -11
  8. package/spec-cli/src/board.ts +47 -18
  9. package/spec-cli/src/boardCache.ts +70 -0
  10. package/spec-cli/src/boardDelta.ts +90 -0
  11. package/spec-cli/src/boardStream.ts +178 -0
  12. package/spec-cli/src/cli.ts +184 -122
  13. package/spec-cli/src/client.ts +6 -4
  14. package/spec-cli/src/gateway.ts +64 -24
  15. package/spec-cli/src/git.ts +105 -92
  16. package/spec-cli/src/guide.ts +186 -19
  17. package/spec-cli/src/harness-select.ts +63 -0
  18. package/spec-cli/src/harness.ts +506 -100
  19. package/spec-cli/src/help.ts +362 -0
  20. package/spec-cli/src/hooks.ts +0 -14
  21. package/spec-cli/src/index.ts +279 -32
  22. package/spec-cli/src/init.ts +41 -1
  23. package/spec-cli/src/issues.ts +301 -0
  24. package/spec-cli/src/layout.ts +70 -28
  25. package/spec-cli/src/lint.ts +12 -10
  26. package/spec-cli/src/listen.ts +28 -0
  27. package/spec-cli/src/localIssues.ts +700 -0
  28. package/spec-cli/src/materialize.ts +182 -27
  29. package/spec-cli/src/mentions.ts +192 -0
  30. package/spec-cli/src/plugin-harness.ts +145 -0
  31. package/spec-cli/src/pty-bridge.ts +378 -81
  32. package/spec-cli/src/self.ts +123 -20
  33. package/spec-cli/src/sessions.ts +461 -298
  34. package/spec-cli/src/specs.ts +55 -14
  35. package/spec-cli/src/supervise.ts +23 -3
  36. package/spec-cli/src/tsx-bin.ts +14 -5
  37. package/spec-cli/src/uninstall.ts +146 -0
  38. package/spec-cli/templates/hooks/post-merge +27 -0
  39. package/spec-cli/templates/hooks/pre-commit +51 -31
  40. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  41. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  42. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  43. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  44. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  45. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  47. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  48. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  49. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  50. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  51. package/spec-cli/templates/spec/project/spec.md +2 -2
  52. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +32 -0
  53. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +145 -0
  54. package/spec-dashboard/dist/index.html +17 -5
  55. package/spec-forge/src/cache.ts +16 -0
  56. package/spec-forge/src/cli.ts +4 -10
  57. package/spec-forge/src/drivers/github.ts +74 -4
  58. package/spec-forge/src/drivers.ts +13 -0
  59. package/spec-forge/src/port.ts +25 -0
  60. package/spec-forge/src/resident.ts +40 -6
  61. package/spec-yatsu/src/cli.ts +227 -38
  62. package/spec-yatsu/src/evaltab.ts +169 -19
  63. package/spec-yatsu/src/filing.ts +48 -0
  64. package/spec-yatsu/src/freshness.ts +55 -20
  65. package/spec-yatsu/src/proof.ts +89 -3
  66. package/spec-yatsu/src/scenariofresh.ts +92 -0
  67. package/spec-yatsu/src/sidecar.ts +75 -11
  68. package/spec-yatsu/src/timeline.ts +47 -0
  69. package/spec-yatsu/src/yatsu.ts +47 -3
  70. package/spec-cli/src/relay.ts +0 -28
  71. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  72. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  73. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -1,21 +1,43 @@
1
1
  export {} // make this a module so top-level await is allowed
2
2
  const cmd = process.argv[2]
3
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}`)
4
+ // Registered before any await so a fatal top-level error lands here. Errors we OWN (BackendError, the
5
+ // loud malformed-config ConfigError) are matched BY NAME — to avoid importing them — and rendered as a
6
+ // one-line `spex: <message>` (a user's config typo must read as their typo, not a SpexCode stack dump);
7
+ // anything else prints in full so a real bug keeps its trace. A synchronous throw inside an awaited call
8
+ // (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
9
+ // paths route through the same printer.
10
+ function fatal(e: unknown): never {
11
+ if (e instanceof Error && (e.name === 'BackendError' || e.name === 'ConfigError')) console.error(`spex: ${e.message}`)
7
12
  else console.error(e)
8
13
  process.exit(1)
9
- })
14
+ }
15
+ process.on('unhandledRejection', fatal)
16
+ process.on('uncaughtException', fatal)
10
17
 
11
18
  // tiny flag reader: --key value (and bare positionals)
12
19
  function flag(name: string): string | undefined {
13
20
  const i = process.argv.indexOf(`--${name}`)
14
21
  return i >= 0 ? process.argv[i + 1] : undefined
15
22
  }
23
+
24
+ // Exit AFTER stdout has flushed. process.exit() force-quits without draining buffered pipe writes, so a
25
+ // large piped dump (`spex issues --json | …`, board, review --json) is silently cut off at the pipe
26
+ // buffer (~64KB). The empty write's callback fires once every prior queued chunk has drained; the returned
27
+ // promise never resolves (process.exit ends the process inside the callback), so `await flushExit(code)`
28
+ // halts execution here exactly like process.exit did — safe to drop in on any unbounded-output verb.
29
+ // EPIPE (a reader that closed early — `| head`, `| jq` exiting) can never drain, so we ALSO exit on the
30
+ // stream error rather than hang: the truncation is the reader's choice then, not ours.
31
+ function flushExit(code = 0): Promise<never> {
32
+ return new Promise<never>(() => {
33
+ const done = () => process.exit(code)
34
+ process.stdout.on('error', done)
35
+ process.stdout.write('', done)
36
+ })
37
+ }
16
38
  const has = (name: string) => process.argv.includes(`--${name}`)
17
39
  // 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'])
40
+ const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api-port', '--host', '--preset'])
19
41
  function positionals(from: number): string[] {
20
42
  const out: string[] = []
21
43
  for (let i = from; i < process.argv.length; i++) {
@@ -26,6 +48,23 @@ function positionals(from: number): string[] {
26
48
  return out
27
49
  }
28
50
 
51
+ // After a successful launch, nudge the caller to actually MONITOR the session — launch-then-forget is a real
52
+ // gap (a supervisor or human launches and then never watches, so a review/failure goes unnoticed). Goes to
53
+ // STDERR so the JSON on stdout (which callers parse) stays clean; keyed to whoever's calling — a supervising
54
+ // agent has an own-session id, a human at a terminal does not.
55
+ async function launchMonitorReminder(id: string): Promise<void> {
56
+ const { ownSessionId } = await import('./sessions.js')
57
+ const agent = ownSessionId()
58
+ console.error(`\nspex: launched session ${id} — now MONITOR it, or its review/failure goes unnoticed:`)
59
+ if (agent) {
60
+ // a supervising agent: the per-worker monitor is a backgrounded `spex wait`, which exits on an actionable status.
61
+ console.error(` supervising agent → background \`spex wait ${id}\` (blocks until it hits an actionable status, then exits)`)
62
+ console.error(` or watch the whole stream: \`spex watch\``)
63
+ } else {
64
+ console.error(` \`spex watch\` — the live stream of actionable session transitions (or \`spex wait ${id}\` to block on this one)`)
65
+ }
66
+ }
67
+
29
68
  const greeted = new Set<string>()
30
69
  async function greetWatchTargets(watcher: string, selectors: string[]): Promise<void> {
31
70
  try {
@@ -76,46 +115,12 @@ async function resolveSelectorOrExit(selector: string): Promise<string> {
76
115
  process.exit(2)
77
116
  }
78
117
 
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.
118
+ // a trailing --help/-h prints help and exits BEFORE any verb runs, so a help probe never fires a
119
+ // streaming/mutating command. It prints THAT command's usage when an entry exists (the second layer
120
+ // of the help journey — see help.ts), falling back to the map for an unknown token.
117
121
  if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
118
- printHelp()
122
+ const { commandHelp, overviewHelp } = await import('./help.js')
123
+ console.log(commandHelp(cmd) ?? overviewHelp())
119
124
  process.exit(0)
120
125
  }
121
126
 
@@ -132,18 +137,32 @@ if (cmd === 'serve') {
132
137
  }
133
138
  await import('./supervise.js')
134
139
  } 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).
140
+ // the natural post-install UI: serve the bundled dashboard on its OWN port (loopback by default;
141
+ // --host widens the bind for LAN/tailnet viewing), proxying /api + the terminal socket to a
142
+ // separately-run `spex serve`. Replaces the dogfood-only `npm run web` (vite).
137
143
  const { serveDashboardLocal } = await import('./gateway.js')
138
144
  const port = Number(flag('port') ?? process.env.SPEXCODE_DASHBOARD_PORT ?? 5173)
139
145
  const apiPort = Number(flag('api-port') ?? process.env.PORT ?? 8787)
146
+ const host = flag('host') ?? '127.0.0.1'
140
147
  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 })
148
+ serveDashboardLocal({ port, apiPort, host })
142
149
  } else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
143
- printHelp()
150
+ // `spex help <cmd>` drills into one command; bare help is the map. Both name the next layer down.
151
+ const { commandHelp, overviewHelp } = await import('./help.js')
152
+ const topic = positionals(3)[0]
153
+ if (cmd === 'help' && topic) {
154
+ const h = commandHelp(topic)
155
+ if (!h) { console.error(`spex help: no command '${topic}' — run \`spex help\` for the map`); process.exit(2) }
156
+ console.log(h)
157
+ } else console.log(overviewHelp())
144
158
  } else if (cmd === 'guide') {
145
159
  const { guideText } = await import('./guide.js')
146
- console.log(guideText(process.argv[3]))
160
+ const text = guideText(process.argv[3])
161
+ if (text === null) {
162
+ console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec, yatsu, config. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
163
+ process.exit(2)
164
+ }
165
+ console.log(text)
147
166
  } else if (cmd === 'owner') {
148
167
  const { specOwners } = await import('./specs.js')
149
168
  const { loadConfig } = await import('./lint.js')
@@ -200,7 +219,13 @@ if (cmd === 'serve') {
200
219
  // scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
201
220
  // into <targetDir> (default cwd). spex init [targetDir]
202
221
  const { specInit } = await import('./init.js')
203
- await specInit(positionals(3)[0])
222
+ await specInit(positionals(3)[0], flag('preset'))
223
+ } else if (cmd === 'uninstall') {
224
+ // the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
225
+ // .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.config data or their own
226
+ // prose. Git hooks preserved unless --hooks. spex uninstall [targetDir] [--hooks]
227
+ const { uninstall } = await import('./uninstall.js')
228
+ uninstall(positionals(3)[0], { hooks: has('hooks') })
204
229
  } else if (cmd === 'review' && positionals(3)[0] === 'proof') {
205
230
  const sel = positionals(3)[1]
206
231
  if (!sel) { console.error('usage: spex review proof <selector> [--open | --out <path> | --json]'); process.exit(2) }
@@ -208,7 +233,7 @@ if (cmd === 'serve') {
208
233
  const { clientProof } = await import('./client.js')
209
234
  const r = await clientProof(id, has('json'))
210
235
  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) }
236
+ if (has('json')) { console.log(r.body); await flushExit(0) }
212
237
  const { writeFileSync } = await import('node:fs')
213
238
  const { join } = await import('node:path')
214
239
  const { tmpdir } = await import('node:os')
@@ -237,7 +262,6 @@ if (cmd === 'serve') {
237
262
  console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
238
263
  console.log(' gates:')
239
264
  console.log(` conflicts w/ main : ${g.conflictsWithMain ? 'YES' : 'no'}`)
240
- console.log(` typecheck : ${g.typecheck.ok ? 'ok' : `${g.typecheck.errorCount} error(s)`}`)
241
265
  console.log(` lint : ${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)`)
242
266
  console.log(` diff (merge-base, ${r.diff.length} file(s)):`)
243
267
  for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
@@ -254,16 +278,32 @@ if (cmd === 'serve') {
254
278
  } else if (cmd === 'forge') {
255
279
  // thin route — all logic lives in spec-forge.
256
280
  const { runForge } = await import('../../spec-forge/src/cli.js')
257
- process.exit(await runForge(process.argv.slice(3)))
281
+ await flushExit(await runForge(process.argv.slice(3)))
258
282
  } else if (cmd === 'yatsu') {
259
283
  // thin route — all logic lives in spec-yatsu.
260
284
  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)))
285
+ await flushExit(await runYatsu(process.argv.slice(3)))
286
+ } else if (cmd === 'blob') {
287
+ // @@@ blob - the bare evidence-transport verb ([[blob-put]]): put bytes in the shared content-addressed
288
+ // cache and print the hash, decoupled from filing a reading. Thin route the cache lives in spec-yatsu.
289
+ const { runBlob } = await import('../../spec-yatsu/src/cli.js')
290
+ await flushExit(runBlob(process.argv.slice(3)))
291
+ } else if (cmd === 'issues') {
292
+ // @@@ issues - the ONE issues surface ([[issues]]): bare it is THE read — local + forge issues as ONE
293
+ // store-tagged list, the supervisor's/human's drain view; a write first-positional (open|reply|sign|
294
+ // resolve|on|off|status, [[local-issues]]) routes to the local store's write verbs, `promote` moves a
295
+ // thread cross-store. (The pre-rename `spex propose` alias is gone — a deployed post-merge hook still
296
+ // calling it prints an unknown-command line, advisory-only, until `npm run hooks` reinstalls it.)
297
+ const { runIssues } = await import('./issues.js')
298
+ await flushExit(await runIssues(process.argv.slice(3)))
299
+ } else if (cmd === 'remark' || cmd === 'resolve' || cmd === 'retract') {
300
+ // @@@ remark - the resolvable interaction primitive ([[remark-substrate]]): pin a concern to a HOST (a
301
+ // local issue, or a scenario `<node> --scenario <name>`) that a second agent can `resolve` and the author
302
+ // can `retract`. CLI-first — the whole author→resolve→retract loop is these thin store-write wrappers, so
303
+ // the dashboard adds no capability. `spex remark <host> --body -|<text> [--code-sha <sha>]`.
304
+ const m = await import('./localIssues.js')
305
+ const run = cmd === 'remark' ? m.runRemark : cmd === 'resolve' ? m.runResolve : m.runRetract
306
+ await flushExit(await run(process.argv.slice(3)))
267
307
  } else if (cmd === 'materialize') {
268
308
  // @@@ materialize - the pay-per-change render: surface nodes → manifest + AGENTS.md/CLAUDE.md block +
269
309
  // shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
@@ -275,17 +315,18 @@ if (cmd === 'serve') {
275
315
  // hooks+handler-existence · backend) over the same HARNESSES materialize renders through; contract prints
276
316
  // the surface:system text; env dumps raw facts. Thin route, like forge/yatsu/hooks.
277
317
  const { runSelf } = await import('./self.js')
278
- process.exit(await runSelf(process.argv.slice(3)))
318
+ await flushExit(await runSelf(process.argv.slice(3)))
279
319
  } else if (cmd === 'board') {
280
320
  const { buildBoard } = await import('./board.js')
281
321
  console.log(JSON.stringify(await buildBoard(), null, 2))
322
+ await flushExit(0)
282
323
  } else if (cmd === 'search') {
283
324
  const { searchSpecs } = await import('./search.js')
284
325
  const query = positionals(3).join(' ')
285
326
  if (!query.trim()) { console.error('usage: spex search <query> [--json] [--limit N]'); process.exit(2) }
286
327
  const limit = Number(flag('limit')) || 10
287
328
  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) }
329
+ if (has('json')) { console.log(JSON.stringify(results)); await flushExit(0) }
289
330
  if (!results.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
290
331
  results.forEach((r, i) => {
291
332
  console.log(`${String(i + 1).padStart(2)}. ${r.title} [${r.id}] · score ${r.score}`)
@@ -293,21 +334,7 @@ if (cmd === 'serve') {
293
334
  if (r.snippet) console.log(` ${r.snippet}`)
294
335
  })
295
336
  process.exit(0)
296
- } else if (cmd === 'relay') {
297
- const { relaySearch } = await import('./relay.js')
298
- const query = positionals(3).join(' ')
299
- if (!query.trim()) { console.error('usage: spex relay <query> [--json] [--limit N]'); process.exit(2) }
300
- const limit = Number(flag('limit')) || 3
301
- const hits = await relaySearch(query, { limit })
302
- if (has('json')) { console.log(JSON.stringify(hits)); process.exit(0) }
303
- if (!hits.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
304
- hits.forEach((h, i) => {
305
- console.log(`${String(i + 1).padStart(2)}. ${h.title} [${h.id}] · score ${h.score}`)
306
- if (h.code.length) h.code.forEach((c) => console.log(` ${c}`))
307
- else console.log(` (no governed code: files — a pure-prose node)`)
308
- })
309
- process.exit(0)
310
- } else if (cmd === 'ls' || cmd === 'sessions') {
337
+ } else if (cmd === 'ls') {
311
338
  // pretty list of living sessions + states. `spex ls [SEL...] [--status a,b] [--json]`
312
339
  // the board comes from the backend (so `spex ls` shows the sessions of whatever SPEXCODE_API_URL points at,
313
340
  // incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
@@ -353,7 +380,9 @@ if (cmd === 'serve') {
353
380
  // it falls back to an in-process launch only when no backend answers.
354
381
  const { createSession } = await import('./sessions.js')
355
382
  const prompt = flag('prompt') ?? positionals(3)[0] ?? ''
356
- console.log(JSON.stringify(await createSession(flag('node') ?? null, prompt, flag('harness') ?? undefined), null, 2))
383
+ const created = await createSession(flag('node') ?? null, prompt, flag('harness') ?? undefined, flag('launcher') ?? undefined)
384
+ console.log(JSON.stringify(created, null, 2))
385
+ await launchMonitorReminder(created.id)
357
386
  } else if (cmd === 'session') {
358
387
  const sub = process.argv[3]
359
388
  // `s` (sessions.ts) backs the state PRODUCERS that stay local (state/done/park/fail/ask/idle write the
@@ -368,19 +397,24 @@ if (cmd === 'serve') {
368
397
  const sess = flag('session')
369
398
  // 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.
370
399
  const DECLARED = ' — recorded; the human sees it in the dashboard. This state lives in your session\'s global record; your next tool call flips that record back to active (the mark-active hook, by design), so it is normal for this declaration not to persist.'
400
+ // appended ONLY to a propose-close declaration: a worktree about to be discarded may still own ephemeral things the agent started to test this change; nudge (not gate) it to reclaim them before the worktree goes, keyed on whether the thing should outlive the task — never on who started it (a deliberately long-running service / a production build is started-by-you yet must be left alone). Project-agnostic on purpose.
401
+ const CLOSE_CLEANUP = '\n\nBefore this worktree closes, check whether you left anything running that you started to test this change — a background process, a dev or preview server, a bound port, a scratch session. If nothing depends on it anymore, shut it down, or it keeps running as an orphan. Leave anything meant to keep running: a service you deliberately stood up, a production build, anything other work relies on. What matters is whether it still needs to exist after this task, not whether you started it. If unsure, leave it. This is a reminder to check, not a required step.'
371
402
  if (sub === 'new') {
372
403
  // route through the backend (auth env + concurrency cap); in-process only if no backend is reachable.
373
404
  // prompt = --prompt OR the first positional (after `session new`), so `session new "<prompt>"` works the
374
405
  // SAME as the `spex new "<prompt>"` shorthand — one prompt-resolution rule, not two.
375
- console.log(JSON.stringify(await s.createSession(flag('node') ?? null, flag('prompt') ?? positionals(4)[0] ?? '', flag('harness') ?? undefined), null, 2))
376
- } else if (sub === 'list') {
377
- console.log(JSON.stringify(await c.clientListSessions(), null, 2))
378
- } else if (sub === 'reopen' || sub === 'resume') {
379
- // "back to working": clear proposal -> active, relaunch if offline (the backend owns the relaunch)
406
+ const created = await s.createSession(flag('node') ?? null, flag('prompt') ?? positionals(4)[0] ?? '', flag('harness') ?? undefined, flag('launcher') ?? undefined)
407
+ console.log(JSON.stringify(created, null, 2))
408
+ await launchMonitorReminder(created.id)
409
+ } else if (sub === 'reopen') {
410
+ // bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
411
+ // `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts reopen()). The
412
+ // RESUME GUARD refuses a relaunch on a LIVE/unproven agent (that would kill a live worker) — `--force`
413
+ // overrides for a genuinely wedged process. A following prompt is what actually re-drives it.
380
414
  const full = await resolveSelectorOrExit(id)
381
- console.log(await c.clientReopen(full) ? `${full} -> working` : `no such session ${full}`)
382
- } else if (sub === 'review') {
383
- console.log(await s.propose(id, 'merge') ? `${id} -> review` : `no such session ${id}`)
415
+ const r = await c.clientReopen(full, process.argv.includes('--force'))
416
+ if (r.ok) console.log(`${full} -> reopened`)
417
+ else { console.error(`spex session reopen: ${r.error || `no such session ${full}`}`); process.exit(2) }
384
418
  } else if (sub === 'state') {
385
419
  // the agent authors ITS OWN state: active|awaiting|parked|error [--propose] [--note] [--session]
386
420
  const st = process.argv[4] as any
@@ -389,7 +423,15 @@ if (cmd === 'serve') {
389
423
  } else if (sub === 'done') {
390
424
  // sugar for awaiting; --propose merge|nothing|close, optional --note
391
425
  const p = (flag('propose') as any) || 'nothing'
392
- console.log(s.markDone(p, sess) ? `done (${p})${DECLARED}` : 'no session record')
426
+ let closeNote = p === 'close' ? CLOSE_CLEANUP : ''
427
+ if (p === 'close') {
428
+ // the DATA half of the close nudge ([[local-issues]] closeoutNudge): the still-open local issues this
429
+ // session touched, listed by id — empty/OFF/no-identity prints nothing. Loud on failure but never
430
+ // gating: the declaration must land whatever the issue store is doing.
431
+ try { closeNote += (await import('./localIssues.js')).closeoutNudge(sess ?? s.ownSessionId()) }
432
+ catch (e) { console.error(`issue closeout check failed (declaration unaffected): ${e instanceof Error ? e.message : e}`) }
433
+ }
434
+ console.log(s.markDone(p, sess) ? `done (${p})${DECLARED}${closeNote}` : 'no session record')
393
435
  } else if (sub === 'park') {
394
436
  // sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
395
437
  console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}` : 'no session record')
@@ -415,15 +457,6 @@ if (cmd === 'serve') {
415
457
  // never clobbering a deliberate awaiting/asking/parked/error declaration. Distinct from `ask`
416
458
  // (the agent deliberately asking the human) — idle is the undeclared stop the Stop gate missed.
417
459
  console.log(s.markIdle(sess) ? 'idle' : 'noop (no session record, or not active)')
418
- } else if (sub === 'merge') {
419
- // merge dispatch (same as top-level `spex merge`): reopen the session and hand its OWN agent the merge
420
- // prompt — the agent runs the --no-ff merge, resolves conflicts, verifies main advanced, and proposes
421
- // close. The SERVER never touches main. Fail-loud: an unreachable agent prints the reason, exits non-zero.
422
- const full = await resolveSelectorOrExit(id)
423
- const r = await c.clientMerge(full)
424
- if (r.dispatched) console.log(`merge dispatched to ${full} — its agent is landing the merge`)
425
- else console.error(`merge dispatch failed: ${r.reason}`)
426
- process.exit(r.dispatched ? 0 : 1)
427
460
  } else if (sub === 'exit') {
428
461
  // the SOFT stop: kill the agent's tmux + socket but KEEP the worktree, so the session goes offline and
429
462
  // can be resumed (reopen/relaunch). Distinct from `close`, which removes the worktree.
@@ -466,35 +499,64 @@ if (cmd === 'serve') {
466
499
  if (!r.ok) { console.error(`no prompt recorded for ${full}`); process.exit(1) }
467
500
  process.stdout.write(r.prompt.endsWith('\n') ? r.prompt : r.prompt + '\n')
468
501
  } else {
469
- console.error('spex session: new|list|reopen|review|done|park|ask|idle|merge|exit|close|send|capture|prompt'); process.exit(2)
502
+ console.error('spex session: new|reopen|done|park|ask|idle|exit|close|send|capture|prompt'); process.exit(2)
470
503
  }
471
- } else if (cmd === 'codex-launch') {
472
- // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
473
- // (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
474
- // SPEXCODE_SESSION_ID), fire the launch prompt as the FIRST turn — materializing the rollout — and print the
475
- // thread id. The launch script then `resume`s it in the visible TUI.
476
- const { codexStartThread, codexTurn } = await import('./harness.js')
477
- const { markHarnessSessionId } = await import('./sessions.js')
478
- const sock = process.argv[3], cwd = process.argv[4]
479
- const prompt = process.argv.slice(5).join(' ')
480
- if (!sock || !cwd) { console.error('usage: spex codex-launch <sock> <cwd> [prompt...]'); process.exit(2) }
481
- const r = await codexStartThread(sock, cwd)
482
- if (!r.ok) { console.error(r.error); process.exit(1) }
483
- const sid = process.env.SPEXCODE_SESSION_ID
484
- if (sid) markHarnessSessionId(sid, r.threadId)
485
- if (prompt) {
486
- const t = await codexTurn(sock, r.threadId, prompt, cwd)
487
- if (!t.ok) { console.error(t.error); process.exit(1) }
504
+ } else if (cmd === 'internal') {
505
+ // @@@ internal - the machine-plumbing namespace: verbs only generated hooks and launch scripts call,
506
+ // kept OUT of the porcelain top level so `spex help`'s vocabulary is exactly what a human/agent types.
507
+ const sub = process.argv[3]
508
+ if (sub === 'trunk') {
509
+ // print the resolved source-of-truth branch (layout.ts mainBranch(): config override → the main
510
+ // checkout's current branch 'main'). The pre-commit main-guard captures this so it blocks direct
511
+ // commits on whatever the repo's trunk is actually named, never a hardcoded 'main'. One value, one
512
+ // line; GET /api/layout exposes the same resolution.
513
+ const { mainBranch } = await import('./layout.js')
514
+ console.log(mainBranch())
515
+ } else if (sub === 'codex-launch') {
516
+ // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
517
+ // (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
518
+ // SPEXCODE_SESSION_ID), fire the launch prompt as the FIRST turn — materializing the rollout — and print the
519
+ // thread id. The launch script then `resume`s it in the visible TUI.
520
+ const { codexStartThread, codexTurn, waitForCodexRollout, codexBinary, codexSupportsBypassHookTrust } = await import('./harness.js')
521
+ const { markHarnessSessionId } = await import('./sessions.js')
522
+ const sock = process.argv[4], cwd = process.argv[5]
523
+ const prompt = process.argv.slice(6).join(' ')
524
+ if (!sock || !cwd) { console.error('usage: spex internal codex-launch <sock> <cwd> [prompt...]'); process.exit(2) }
525
+ // On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
526
+ // the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
527
+ // the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
528
+ const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex --yolo'))
529
+ const r = await codexStartThread(sock, cwd, bypassHookTrust)
530
+ if (!r.ok) { console.error(r.error); process.exit(1) }
531
+ if (prompt) {
532
+ const t = await codexTurn(sock, r.threadId, prompt, cwd)
533
+ if (!t.ok) { console.error(t.error); process.exit(1) }
534
+ // The visible TUI resumes this thread from its ON-DISK rollout; a freshly-spawned app-server acks the turn
535
+ // but persists the rollout a few seconds LATE (verified live: the SAME thread's file lands at ~2-4s, not
536
+ // lost). WAIT for it to land BEFORE storing the id / printing it, else FAIL LOUD — never store a
537
+ // non-resumable harness_session_id (that permanently wedges every reopen). The 15s budget exceeds launch.sh's
538
+ // fast-fail threshold, so a real failure exits past it and the retry loop won't spray duplicate-prompt threads.
539
+ if (!await waitForCodexRollout(r.threadId, 20000)) {
540
+ console.error(`codex thread ${r.threadId} started but persisted no rollout within 20s — app-server not ready; not storing a non-resumable id`)
541
+ process.exit(1)
542
+ }
543
+ }
544
+ const sid = process.env.SPEXCODE_SESSION_ID
545
+ if (sid) markHarnessSessionId(sid, r.threadId)
546
+ console.log(r.threadId)
547
+ } else if (sub === 'codex-turn') {
548
+ // fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
549
+ // tests / scripts). steer-vs-start is chosen live from the thread read.
550
+ const { codexTurn } = await import('./harness.js')
551
+ const sock = process.argv[4], tid = process.argv[5], text = process.argv.slice(6).join(' ')
552
+ if (!sock || !tid || !text) { console.error('usage: spex internal codex-turn <sock> <threadId> <text...>'); process.exit(2) }
553
+ const r = await codexTurn(sock, tid, text)
554
+ if (r.ok) { console.log('ok') } else { console.error(r.error); process.exit(1) }
555
+ } else {
556
+ const { commandHelp } = await import('./help.js')
557
+ console.error(commandHelp('internal'))
558
+ process.exit(2)
488
559
  }
489
- console.log(r.threadId)
490
- } else if (cmd === 'codex-turn') {
491
- // fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
492
- // tests / scripts). steer-vs-start is chosen live from the thread read.
493
- const { codexTurn } = await import('./harness.js')
494
- const sock = process.argv[3], tid = process.argv[4], text = process.argv.slice(5).join(' ')
495
- if (!sock || !tid || !text) { console.error('usage: spex codex-turn <sock> <threadId> <text...>'); process.exit(2) }
496
- const r = await codexTurn(sock, tid, text)
497
- if (r.ok) { console.log('ok') } else { console.error(r.error); process.exit(1) }
498
560
  } else {
499
561
  console.error(`spex: unknown command '${cmd}' (try: spex help)`)
500
562
  process.exit(2)
@@ -74,10 +74,12 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
74
74
  return await r.json().catch(() => ({ dispatched: false, reason: `bad backend response (${r.status})` }))
75
75
  }
76
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
77
+ // POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
78
+ // working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
79
+ // `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
80
+ export async function clientReopen(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
81
+ const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
82
+ return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
81
83
  }
82
84
 
83
85
  // POST /api/sessions/:id/exit — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,