spexcode 0.2.1 → 0.2.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 (45) hide show
  1. package/README.md +147 -103
  2. package/README.zh-CN.md +123 -88
  3. package/package.json +1 -1
  4. package/spec-cli/bin/spex.mjs +24 -1
  5. package/spec-cli/src/attach.ts +50 -0
  6. package/spec-cli/src/cli.ts +214 -62
  7. package/spec-cli/src/client.ts +47 -9
  8. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  9. package/spec-cli/src/guide.ts +63 -11
  10. package/spec-cli/src/harness.ts +48 -19
  11. package/spec-cli/src/help.ts +137 -49
  12. package/spec-cli/src/index.ts +31 -11
  13. package/spec-cli/src/issues.ts +48 -21
  14. package/spec-cli/src/layout.ts +4 -4
  15. package/spec-cli/src/localIssues.ts +44 -60
  16. package/spec-cli/src/materialize.ts +4 -2
  17. package/spec-cli/src/mentions.ts +22 -1
  18. package/spec-cli/src/pty-bridge.ts +39 -4
  19. package/spec-cli/src/ranker.ts +31 -12
  20. package/spec-cli/src/search.bench.mjs +30 -7
  21. package/spec-cli/src/search.ts +39 -0
  22. package/spec-cli/src/sessions.ts +149 -62
  23. package/spec-cli/src/specs.ts +16 -4
  24. package/spec-cli/src/supervise.ts +30 -6
  25. package/spec-cli/src/tree.ts +118 -0
  26. package/spec-cli/templates/hooks/post-merge +2 -2
  27. package/spec-cli/templates/hooks/pre-commit +34 -15
  28. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  29. package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
  30. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  31. package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
  32. package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
  33. package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
  34. package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
  35. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
  37. package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
  38. package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
  39. package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
  40. package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
  41. package/spec-dashboard/dist/index.html +2 -2
  42. package/spec-yatsu/src/cli.ts +89 -15
  43. package/spec-yatsu/src/scenariofresh.ts +100 -30
  44. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
  45. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
@@ -12,8 +12,19 @@ type Entry = { line: string; body: string; see?: string }
12
12
  const SEL_NOTE = `SEL = session id (or unique id-prefix) | node id | branch — every session read/control verb
13
13
  accepts any of the three; none (or @all) means every session.`
14
14
 
15
+ const ROUTING_NOTE = `Backend routing: every session verb accepts --api <url> (--port <n> = localhost sugar) to name its
16
+ backend explicitly — the flag always wins. Bare, it resolves: worker env / the cwd project's live
17
+ recorded backend / fallback / :8787 (spex guide config → BACKEND ROUTING).`
18
+
15
19
  // aliases resolve to a canonical entry so `spex help session` and `spex session new --help` meet the same text.
16
- const ALIAS: Record<string, string> = { 'review-proof': 'review', help: 'help' }
20
+ // The session-sub tokens mirror the CLI's verb-mirror rule: each typeable sub also answers bare at the top
21
+ // level, so its help probe (`spex send --help`, `spex help send`) must land on the session entry, not dead-end.
22
+ const SESSION_SUB_TOKENS = ['reopen', 'done', 'park', 'ask', 'exit', 'close', 'send', 'capture', 'attach', 'rename', 'rawkey', 'prompt']
23
+ const ALIAS: Record<string, string> = {
24
+ 'review-proof': 'eval',
25
+ help: 'help',
26
+ ...Object.fromEntries(SESSION_SUB_TOKENS.map((t) => [t, 'session'])),
27
+ }
17
28
 
18
29
  const ENTRIES: Record<string, Entry> = {
19
30
  // ── find & read the graph ─────────────────────────────────────────────────
@@ -23,25 +34,42 @@ const ENTRIES: Record<string, Entry> = {
23
34
 
24
35
  Finds the spec node(s) whose INTENT matches your topic — ranked by user-story relevance, which
25
36
  surfaces user-facing behaviour a code-grep misses. Run it BEFORE touching code: the returned node's
26
- spec.md body is the current contract for that area. Prints title, id, path, snippet per hit.`,
37
+ spec.md body is the current contract for that area. Prints title, id, path, snippet per hit.
38
+ The corpus is English — query in English (translate first if your question isn't).`,
27
39
  see: 'spex owner (file → node, the reverse edge) · spex guide spec (what a node is)',
28
40
  },
29
41
  owner: {
30
- line: 'owner <path> the reverse edge: which spec node(s) claim a file',
42
+ line: 'owner <path> the reverse edge: which spec node(s) govern or reference a file',
31
43
  body: `Usage: spex owner <path> [--actionable]
32
44
 
33
- Maps a source file to the spec node(s) governing it, with the verdict spelled out: uncovered
34
- ("give it a home"), sanely governed (read/honor that spec), or over-owned (> maxOwners — split the
35
- file). --actionable prints NOTHING for the sane case (hook use: only fires when action is needed).`,
45
+ Maps a source file to BOTH spec relations: its GOVERNORS (code: the source of truth; drives
46
+ drift/yatsu) and its REFERENCERS (related: coverage only), with the verdict spelled out:
47
+ uncovered ("give it a home"), related-only (covered, but nothing tracks its drift), sanely
48
+ governed (read/honor that spec), or over-owned (> maxOwners — split the file). --actionable
49
+ prints NOTHING unless action is needed (hook use): only uncovered / over-owned fire.`,
36
50
  see: 'spex search (topic → node) · spex lint (coverage over the whole tree)',
37
51
  },
52
+ tree: {
53
+ line: 'tree the graph as a human-readable tree (status-coloured, badges)',
54
+ body: `Usage: spex tree [--node <id>] [--depth N] [--json]
55
+
56
+ Prints the assembled spec graph as an indented tree — the CLI twin of the dashboard's tidy-tree,
57
+ built from the same board (merged tree + worktree overlay). One line per node: id, derived status
58
+ (coloured when stdout is a tty; NO_COLOR respected — the status word always prints), title, and
59
+ attention badges: drift:N (drifted files), stale:N (yatsu scenarios whose latest reading aged),
60
+ issues:N (open issues), ghost (being added by a worktree).
61
+ --node <id> render just that subtree (unknown id fails loud)
62
+ --depth N limit levels below the shown root; prunes are counted, never silent
63
+ --json the same filtered subtree as nested objects, badge counts precomputed`,
64
+ see: 'spex board (the full flat JSON payload) · spex search (find one node by intent)',
65
+ },
38
66
  board: {
39
67
  line: 'board dump the assembled board as JSON (tree · overlay · sessions)',
40
68
  body: `Usage: spex board
41
69
 
42
70
  Prints the full dashboard board state as JSON — the merged spec tree, per-worktree overlay, and the
43
71
  session list. Identical to GET /api/board; needs the backend (spex serve) reachable.`,
44
- see: 'spex ls (just the sessions, as a table) · spex search (find one node instead of dumping all)',
72
+ see: 'spex tree (the same graph, human-readable) · spex ls (just the sessions, as a table) · spex search (find one node instead of dumping all)',
45
73
  },
46
74
  guide: {
47
75
  line: 'guide [topic] the manuals: setup workflow · spec/yatsu file formats · spexcode.json',
@@ -82,40 +110,52 @@ only the trailer. If the intent DID change, edit the spec instead — same commi
82
110
  yatsu: {
83
111
  line: 'yatsu <sub> measure a node’s scenarios & file the loss signal: scan | eval | retract | show | clean',
84
112
  body: `Usage: spex yatsu scan [--changed] list nodes/scenarios missing readings
85
- spex yatsu eval [.|<node>] [--scenario <name>] (--pass|--fail)
86
- [--note <text>] [--image <png> | --result <path|->]
113
+ spex yatsu eval [.|<node>] [--scenario <name>] (--pass|--fail) [--note <text>]
114
+ [--image <png>] [--result <path|->] [--video <webm|mp4> [--timeline <json>]]
87
115
  spex yatsu retract [.|<node>] [--scenario <name>] [--last | --ts <iso>] [--note <why>]
88
116
  spex yatsu show [.|<node>] [--json] readings history for a node
89
117
  spex yatsu clean [--keep-latest | --all] prune stored readings
90
118
 
91
119
  Files a reading of a scenario against its expected — the loss signal the optimizer reads. Measure
92
- through the REAL product surface (run it, drive a browser, screenshot), never by reasoning about
93
- the code. A fix's proof is a fail→pass pair on the SAME scenario. \`retract\` is the sanctioned
94
- undo for a botched filing: it APPENDS a retraction event (traceable, never deletes a line).`,
120
+ through the REAL product surface (run it, drive a browser, capture), never by reasoning about
121
+ the code. Evidence kind follows the behaviour: MOVING/timed behaviour (scroll, animation,
122
+ playback, a multi-step flow) records a \`--video\`; a STATIC end state screenshots \`--image\`;
123
+ backend/CLI files a \`--result\` transcript. A fix's proof is a fail→pass pair on the SAME
124
+ scenario. \`retract\` is the sanctioned undo for a botched filing: it APPENDS a retraction
125
+ event (traceable, never deletes a line).`,
95
126
  see: 'spex guide yatsu (yatsu.md format + evidence rules) · spex blob (stash evidence bytes)',
96
127
  },
97
128
  blob: {
98
- line: 'blob put <file|-> stash evidence bytes in the content-addressed cache, print the hash',
129
+ line: 'blob put|get evidence bytes content hash: put stashes & prints the hash, get reads back',
99
130
  body: `Usage: spex blob put <file|->
131
+ spex blob get <hash> [-o <file>]
100
132
 
101
- Writes bytes into the shared content-addressed evidence cache and prints the hash — transport only,
102
- no reading filed. Use the hash with --evidence on issues/remarks; re-putting the same content
103
- restores a pruned or cloned-away blob.`,
133
+ put writes bytes into the shared content-addressed evidence cache and prints the hash — transport
134
+ only, no reading filed. Use the hash with --evidence on issues/remarks; re-putting the same content
135
+ restores a pruned or cloned-away blob.
136
+
137
+ get is the symmetric read: hash in, bytes out. Local cache first (no backend needed — the evidence
138
+ is usually on this disk), then the backend's /api/yatsu/blob/:hash on a local miss; both missing
139
+ fails loud naming each path. Bytes go to stdout by default (pipe-friendly); -o writes a file.`,
104
140
  see: 'spex yatsu eval (file a reading with evidence) · spex issues open --evidence <hash>',
105
141
  },
106
142
  issues: {
107
143
  line: 'issues … THE issue surface: one merged local+forge list, plus all write verbs',
108
144
  body: `Usage: spex issues the merged read (local + forge, store-tagged)
109
145
  [--node <id>] [--store local|github] [--all] [--json]
110
- spex issues open "<concern>" [--node <id>…] [--evidence <hash>…] [--body -|<text>]
146
+ spex issues open "<concern>" [--store local|<host>] [--node <id>…] [--evidence <hash>…] [--body -|<text>]
111
147
  spex issues reply <id> --body -|<text> [--evidence <hash>…] (routes by the issue's store)
112
- spex issues sign <id> co-sign a local issue
113
- spex issues resolve <id> --as accepted|rejected|landed
148
+ spex issues close <id> close by the issue's store: local lands, forge closes remote
114
149
  spex issues promote <id> move an OPEN local issue to the forge (one recorded action)
115
150
  spex issues on|off|status toggle/inspect the local-issue workflow
116
151
 
117
152
  Bare \`spex issues\` is the drain view a supervisor reads. \`open\` welcomes taste, annotations, and
118
- off-mainline smells — not only bugs. (\`nudge\` exists but is fired by the post-merge hook, not typed.)`,
153
+ off-mainline smells — not only bugs; \`--store <host>\` opens straight on the forge (the same port the
154
+ dashboard's New form uses). \`close\` and \`reply\` route by the issue's store — one verb, local or forge,
155
+ the same routing as the dashboard. (\`nudge\` exists but is fired by the post-merge hook, not typed.)
156
+ Mentions: @session · [[node]] work in any concern/body — CLI args included. [[node]] links the topic
157
+ node (it also tags the issue, like --node); @session hands the text to that live agent; @new spawns
158
+ a fresh worker on the thread's node.`,
119
159
  see: 'spex remark (pin a resolvable concern to an issue or scenario) · spex forge (trace forge → nodes)',
120
160
  },
121
161
  remark: {
@@ -138,35 +178,27 @@ Resolves a forge's open issues/PRs to the spec nodes they serve (the Spec: marke
138
178
  a node/<id> branch links its PR for free). Read-only — git/.spec stays the single source of truth.`,
139
179
  see: 'spex issues (the merged read that includes forge threads)',
140
180
  },
141
- self: {
142
- line: 'self <sub> diagnose whether the workflow actually reaches THIS agent: doctor | contract | conflicts',
143
- body: `Usage: spex self [doctor] per-layer coverage report: preconditions · git-hook floor · contract ·
144
- hooks + handler existence · backend — for every harness materialize renders
145
- spex self contract print the composed surface:system text any agent here reads
146
- spex self conflicts detect double-delivery (loose artifacts beside the managed ones)
147
-
148
- Run it when a worker seems to be missing its contract or hooks — it names the broken layer and the
149
- repair, instead of you diffing materialized files by hand.`,
150
- see: 'spex materialize (re-render the artifacts doctor checks)',
151
- },
152
-
153
181
  // ── dispatch & manage sessions (manager loop) ─────────────────────────────
154
182
  new: {
155
- line: 'new "<prompt>" launch a worker session in its own node worktree [--node <id>]',
156
- body: `Usage: spex new "<task prompt>" [--node <id>] [--harness <name>] [--launcher <cmd>]
183
+ line: 'new "<prompt>" launch a worker session in its own node worktree [--node <id>] [--launcher <name>]',
184
+ body: `Usage: spex new "<task prompt>" [--node <id>] [--launcher <name>]
157
185
 
158
186
  Creates a session: node branch + worktree + a launched agent carrying your prompt (= session new).
159
187
  Give it ONLY its task — the dev-flow contract reaches it through the materialized system prompt.
188
+ The launcher name selects both the agent harness and the command/auth profile (built-ins: claude, codex);
189
+ omitting it requires sessions.defaultLauncher in spexcode.json or spexcode.local.json.
160
190
  Routes through the running backend (auth env + concurrency cap); prints the created session JSON.
161
191
  Then MONITOR it: background \`spex wait <id>\`, or \`spex watch\` for the whole stream.`,
162
192
  see: 'spex wait / spex watch (monitor) · spex review (when it proposes) · ' + SEL_NOTE.split('\n')[0],
163
193
  },
164
194
  ls: {
165
- line: 'ls [SEL…] living-sessions table [--status a,b] [--json]',
166
- body: `Usage: spex ls [SEL…] [--status a,b] [--json]
195
+ line: 'ls [SEL…] living-sessions table [--status a,b] [--json] [--api URL]',
196
+ body: `Usage: spex ls [SEL…] [--status a,b] [--json] [--api <url> | --port <n>]
167
197
 
168
- One-shot table of living sessions and their states, from whatever backend SPEXCODE_API_URL points
169
- at (including a remote machine's). ${SEL_NOTE}`,
198
+ One-shot table of living sessions and their states, from the resolved backend bare \`spex ls\` in a
199
+ project's tree hits THAT project's live backend; --api <url> (or --port <n>) points it anywhere,
200
+ including a remote machine's. ${SEL_NOTE}
201
+ ${ROUTING_NOTE}`,
170
202
  see: 'spex watch (the live stream) · spex wait (block on one session)',
171
203
  },
172
204
  watch: {
@@ -194,14 +226,29 @@ ${SEL_NOTE}`,
194
226
  review: {
195
227
  line: 'review <SEL> manager cockpit: ahead · merge-base diff · gates · proposal [--json]',
196
228
  body: `Usage: spex review <SEL> [--json]
197
- spex review proof <SEL> [--open | --out <path> | --json]
198
229
 
199
230
  The ONE review payload for a session: commits ahead of the trunk, uncommitted files, its proposal,
200
231
  the gates (conflicts with the trunk, lint), and the merge-base diff — decide from this, don't
201
- hand-run git. \`review proof\` renders the session's proof-of-work as self-contained HTML (diff ·
202
- measured yatsu loss · gates), fully derived from git + readings.
232
+ hand-run git. The MEASURED side of the decision is \`spex eval <SEL>\`: the changed nodes' eval
233
+ readings, and (--export) the self-contained HTML export.
203
234
  ${SEL_NOTE}`,
204
- see: 'spex merge (act on an approved review)',
235
+ see: 'spex eval (the session’s measured loss) · spex merge (act on an approved review)',
236
+ },
237
+ eval: {
238
+ line: 'eval <SEL> the session’s eval readings: its changed nodes’ measured loss [--export]',
239
+ body: `Usage: spex eval <SEL> [--json]
240
+ spex eval <SEL> --export [--open | --out <path> | --json]
241
+
242
+ The session's evaluation, read from the backend (the dashboard Eval tab's CLI twin): every spec node
243
+ the session's diff touches, each DECLARED scenario at its CURRENT score (latest reading, rooted at
244
+ the session's worktree). Blind spots lead (declared, never measured — the outstanding loss), then
245
+ the session's OWN measurements ✦-marked, then the inherited baseline (other sessions' latest
246
+ readings) under an explicit divider. A frontend change with no yatsu.md is flagged, never hidden.
247
+ --export writes the evaluation as ONE self-contained HTML artifact instead (diff · evidence
248
+ inlined · gates; --json = the model) for CI/sharing. (\`spex review proof\` is its deprecated alias.)
249
+ This is the READ; filing a reading stays \`spex yatsu eval\`.
250
+ ${SEL_NOTE}`,
251
+ see: 'spex review (gates + diff — the merge decision) · spex yatsu eval (FILE a reading, the write verb)',
205
252
  },
206
253
  merge: {
207
254
  line: 'merge <SEL> gated merge into the trunk — dispatched to the session’s own agent',
@@ -210,11 +257,13 @@ ${SEL_NOTE}`,
210
257
  Dispatches the merge to the session's OWN agent (it knows the work's intent and resolves conflicts);
211
258
  the server never touches the trunk's tree. Gates re-check first. After it lands, confirm HEAD
212
259
  advanced before closing the session — closing an unmerged branch discards the work.
260
+ Mutating verbs are PROJECT-BOUND: a backend serving another project's repo refuses the write loudly
261
+ (name the target with --api <url> to write cross-project on purpose).
213
262
  ${SEL_NOTE}`,
214
263
  see: 'spex review (before) · spex session close (after the merge is confirmed)',
215
264
  },
216
265
  session: {
217
- line: 'session <sub> the state machine: new·reopen·done·park·ask·exit·close·send·capture·prompt',
266
+ line: 'session <sub> every session verb answers here: new·ls·watch·wait·review·merge·reopen·done·park·ask·exit·close·send·capture·attach·rename·rawkey·prompt',
218
267
  body: `Worker verbs (declare YOUR OWN state — a claim the board and your supervisor act on):
219
268
  spex session done --propose merge|nothing|close [--note T] committed and stopping; merge = ready for review
220
269
  spex session park --note <what-you-await> a real background task will wake you
@@ -223,14 +272,36 @@ ${SEL_NOTE}`,
223
272
  Manager verbs (control another session; all take SEL):
224
273
  spex session send <SEL> "<msg>" deliver a message (fail-loud: a dead dispatch exits non-zero)
225
274
  spex session capture <SEL> the live pane as text
275
+ spex session rename <SEL> "<name>" set the display name ("" clears; the right-click rename, as a verb)
276
+ spex session rawkey <SEL> "<keys>" raw nav keys to a TUI dialog, in strike order (e.g. "Up Up Enter";
277
+ named keys · single chars · C-/M-/S- combos; fail-loud)
226
278
  spex session prompt <SEL> the session's originating prompt
227
279
  spex session reopen <SEL> [--force] relaunch ONLY if confirmed offline (--force for a wedged live one)
228
280
  spex session exit <SEL> soft stop: kill the agent, KEEP the worktree (resumable)
229
281
  spex session close <SEL> retire the session and its worktree
230
- spex session new "<prompt>" = spex new
282
+
283
+ Promoted verbs — they answer in this drawer too (same verb, either drawer):
284
+ spex session new|ls|watch|wait|review|merge … ≡ spex new|ls|watch|wait|review|merge …
285
+ And the reverse holds for every sub above: it also answers bare at the top level
286
+ (spex send <SEL> "…" ≡ spex session send <SEL> "…"). One implementation, two spellings —
287
+ you never have to guess which drawer a session verb lives in.
288
+
289
+ Human escape hatch:
290
+ spex session attach <SEL> sit in the worker's REAL tmux (detach: C-b d; the session keeps
291
+ running). INTERACTIVE AND BLOCKING — like watch, an agent must
292
+ NEVER run it in a turn (it freezes you): use capture/send/rawkey.
293
+ LOCAL-only — the tmux server is the backend machine's, so it fails
294
+ loud when the resolved backend is remote. Offline session → loud.
295
+
296
+ Mentions: @session · [[node]] work in ANY prompt, issue, or remark body — text passed as a CLI arg
297
+ included. [[node]] names the topic (a new session derives its node from the prompt's first one);
298
+ @session hands the surrounding text to that live agent; @new dispatches a fresh worker.
231
299
 
232
300
  (state · fail · idle · commit-gate also exist but are hook-driven — the lifecycle hooks call them;
233
- never type them.) ${SEL_NOTE}`,
301
+ never type them.) ${SEL_NOTE}
302
+ Manager verbs that WRITE (send/rename/rawkey/reopen/exit/close) are PROJECT-BOUND: a backend serving
303
+ another project's repo refuses loudly — name the target with --api <url> to drive it on purpose.
304
+ ${ROUTING_NOTE}`,
234
305
  see: 'spex new (launch) · spex wait/watch (monitor) · spex review/merge (land)',
235
306
  },
236
307
 
@@ -261,7 +332,18 @@ Renders the surface:system config nodes into the managed <!-- spexcode --> block
261
332
  CLAUDE.md/AGENTS.md plus the .claude/.codex shims, and prints the content hash. Run it after a
262
333
  toolchain update or any .config edit that the automatic dispatch gate hasn't picked up — these
263
334
  artifacts are generated and gitignored, so they never arrive via git.`,
264
- see: 'spex self doctor (verify the render actually reaches an agent)',
335
+ see: 'spex doctor (verify the render actually reaches an agent)',
336
+ },
337
+ doctor: {
338
+ line: 'doctor diagnose whether the workflow actually reaches this agent — per-layer, per-harness',
339
+ body: `Usage: spex doctor per-layer coverage report: preconditions · git-hook floor · contract ·
340
+ hooks + handler existence · backend — for every harness materialize renders
341
+ spex doctor contract print the composed surface:system text any agent here reads
342
+ spex doctor conflicts detect double-delivery (loose artifacts beside the managed ones)
343
+
344
+ Run it when a worker seems to be missing its contract or hooks — it names the broken layer and the
345
+ repair, instead of you diffing materialized files by hand.`,
346
+ see: 'spex materialize (re-render the artifacts doctor checks)',
265
347
  },
266
348
  serve: {
267
349
  line: 'serve run the API backend (default :8787) [--port N] [--public --password pw]',
@@ -270,8 +352,10 @@ artifacts are generated and gitignored, so they never arrive via git.`,
270
352
 
271
353
  Runs the backend for the repo at cwd behind a zero-downtime supervisor (hot-reloads on source
272
354
  change; the public port never gaps). --port pairs with \`spex dashboard --api-port\`, so many
273
- projects coexist on one host. --public exposes it on a public IP behind a password + self-signed
274
- TLS (own cert via --tls-cert/--tls-key; --http drops TLS).`,
355
+ projects coexist on one host. On a successful bind it RECORDS its endpoint in the per-project
356
+ runtime tier that's how a bare \`spex\` run from this project's tree finds this backend (see
357
+ spex guide config → BACKEND ROUTING). --public exposes it on a public IP behind a password +
358
+ self-signed TLS (own cert via --tls-cert/--tls-key; --http drops TLS).`,
275
359
  see: 'spex dashboard (the UI on top) · GET /health (liveness probe)',
276
360
  },
277
361
  dashboard: {
@@ -326,6 +410,7 @@ Usage: spex <command> [args] one command's usage: spex help <command> (or
326
410
  Find & read the graph
327
411
  ${ENTRIES.search.line}
328
412
  ${ENTRIES.owner.line}
413
+ ${ENTRIES.tree.line}
329
414
  ${ENTRIES.board.line}
330
415
  ${ENTRIES.guide.line}
331
416
 
@@ -337,7 +422,6 @@ Author & verify (the worker loop)
337
422
  ${ENTRIES.issues.line}
338
423
  ${ENTRIES.remark.line}
339
424
  ${ENTRIES.forge.line}
340
- ${ENTRIES.self.line}
341
425
 
342
426
  Dispatch & manage sessions (the manager loop)
343
427
  ${ENTRIES.new.line}
@@ -345,6 +429,7 @@ Dispatch & manage sessions (the manager loop)
345
429
  ${ENTRIES.watch.line}
346
430
  ${ENTRIES.wait.line}
347
431
  ${ENTRIES.review.line}
432
+ ${ENTRIES.eval.line}
348
433
  ${ENTRIES.merge.line}
349
434
  ${ENTRIES.session.line}
350
435
 
@@ -352,11 +437,14 @@ Install & serve (the operator loop)
352
437
  ${ENTRIES.init.line}
353
438
  ${ENTRIES.uninstall.line}
354
439
  ${ENTRIES.materialize.line}
440
+ ${ENTRIES.doctor.line}
355
441
  ${ENTRIES.serve.line}
356
442
  ${ENTRIES.dashboard.line}
357
443
 
358
444
  ${SEL_NOTE}
359
445
 
446
+ ${ROUTING_NOTE}
447
+
360
448
  Concepts & best practice live in the guide: spex guide (setup) · guide spec · guide yatsu · guide config.
361
449
  Machine plumbing (hook/launch-script callees) lives under \`spex internal\` — not part of your vocabulary.`
362
450
  }
@@ -5,7 +5,7 @@ import { etag } from 'hono/etag'
5
5
  import { createNodeWebSocket } from '@hono/node-ws'
6
6
  import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig } from './specs.js'
7
7
  import { issuesEnabled, remarkOnHost, resolveRemark, retractRemark } from './localIssues.js'
8
- import { closeIssue, createIssue, issueStores, mergedIssues, replyIssue } from './issues.js'
8
+ import { closeIssue, createIssue, issueStores, mergedIssues, promote, replyIssue } from './issues.js'
9
9
  import { residentForgeState, refreshForgeNow } from '../../spec-forge/src/resident.js'
10
10
  import { summarize } from './mentions.js'
11
11
  import { resolveLayout, mainBranch } from './layout.js'
@@ -13,7 +13,7 @@ import { getBoardJson } from './boardCache.js'
13
13
  import { boardStream, notifyBoardChanged } from './boardStream.js'
14
14
  import { gitA, gitTry, repoRoot } from './git.js'
15
15
  import { newSession, listSessions, sendKeys, rawKey, exitSession, closeSession, reopen, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, superviseQueue } from './sessions.js'
16
- import { defaultHarness, HARNESSES, launcherList, defaultLauncher } from './harness.js'
16
+ import { defaultHarness, HARNESSES, launcherList, launcherDefault } from './harness.js'
17
17
  import { evalTimeline, readBlobByHash } from '../../spec-yatsu/src/evaltab.js'
18
18
  import { putBlob } from '../../spec-yatsu/src/cache.js'
19
19
  import { yatsuNodes } from '../../spec-yatsu/src/yatsu.js'
@@ -146,11 +146,11 @@ app.get('/api/config', (c) => c.json(loadConfig()))
146
146
  // the named launcher profiles ([[launcher-select]]) the New-Session form's dropdown offers — `{ name, harness }`
147
147
  // only (the `cmd` is a host secret, never shipped to the browser) — plus the configured `default` NAME so the
148
148
  // dropdown pre-selects the SAME launcher a bare `spex new` uses (the CLI/config default), instead of the
149
- // alphabetically-first one. `launchers` is empty (and `default` '') when a project configured none, so the form
150
- // falls back to the plain harness picker.
149
+ // alphabetically-first one. Missing defaultLauncher is returned as an actionable config error, not hidden by
150
+ // falling through to the built-in `claude` launcher.
151
151
  app.get('/api/launchers', (c) => c.json({
152
152
  launchers: launcherList().map(({ name, harness }) => ({ name, harness })),
153
- default: defaultLauncher(),
153
+ ...launcherDefault(),
154
154
  }))
155
155
  // the ISSUES read surface ([[issues]]) for the dashboard's issues page — the merged list over every store
156
156
  // (local threads + the resident forge slice), the SAME mergedIssues() the CLI drain reads, verbatim
@@ -224,14 +224,34 @@ app.post('/api/issues', async (c) => {
224
224
  }
225
225
  })
226
226
 
227
+ // promotion moves an open local thread to the forge as one recorded action ([[issues]]'s promote verb,
228
+ // verbatim: forge issue first, then the permalink reply + local close. The forced forge read-back means
229
+ // the reload that follows shows the promoted issue in the merged list. Fail loud: an unreachable forge is a
230
+ // 502 with the local thread untouched.
231
+ app.post('/api/issues/:id/promote', async (c) => {
232
+ if (!issuesEnabled()) return c.json({ error: 'issues workflow is off' }, 403)
233
+ const id = c.req.param('id')
234
+ if (id.includes('#')) return c.json({ error: 'only a local issue promotes' }, 400)
235
+ try {
236
+ const r = await promote(id, { author: 'human' })
237
+ await refreshForgeNow()
238
+ return c.json({ ok: true, ...r })
239
+ } catch (e) {
240
+ const msg = String((e as Error).message || e)
241
+ return c.json({ error: msg }, /^no local issue/.test(msg) ? 404 : 502)
242
+ }
243
+ })
244
+
227
245
  // the REMARK write surface ([[remark-substrate]]) — server PARITY with the CLI: the dashboard can author /
228
246
  // resolve / retract a remark through the SAME functions `spex remark|resolve|retract` call, adding no
229
247
  // capability. A ref (`<thread-id>#<rid>`) rides the request BODY, not the path (a '#' in a URL is a
230
248
  // fragment). Identity is derived SERVER-SIDE — this is the dashboard's human surface, so the actor is
231
249
  // `'human'`, the SAME sentinel /api/issues stamps; it is NEVER read from the request body. That keeps R3's
232
- // teeth structural (identity is not spoofable over the wire) and honest on both surfaces: resolve rejects
233
- // `'human'` (agent-onlyan agent resolves through the CLI, never the dashboard), and retract binds to the
234
- // human who authored (only their own `'human'` remarks). Who-may-resolve/retract cannot depend on transport.
250
+ // teeth structural (identity is not spoofable over the wire) and identical on both surfaces: resolve is any
251
+ // SECOND party's deliberate judgment the human resolves an agent's remark here exactly as an agent
252
+ // resolves through the CLI, and self-resolve stays rejected by the same identity comparison ('human' can
253
+ // never resolve a human-authored remark) — and retract binds to the author (only the human's own remarks).
254
+ // Who-may-resolve/retract cannot depend on transport.
235
255
  app.post('/api/remarks', async (c) => {
236
256
  if (!issuesEnabled()) return c.json({ error: 'issues workflow is off' }, 403)
237
257
  const body = await c.req.json().catch(() => ({}))
@@ -306,15 +326,15 @@ app.post('/api/sessions', async (c) => {
306
326
  const body = await c.req.json().catch(() => ({}))
307
327
  const prompt = typeof body?.prompt === 'string' ? body.prompt : ''
308
328
  if (!prompt.trim()) return c.json({ error: 'empty prompt' }, 400)
309
- const harness = typeof body?.harness === 'string' ? body.harness : undefined
329
+ if (typeof body?.harness === 'string') return c.json({ error: 'harness is not a create-session input; use launcher' }, 400)
310
330
  // the named launcher ([[launcher-select]]) — fixes the session's harness AND its persisted launch command.
311
331
  const launcher = typeof body?.launcher === 'string' && body.launcher.trim() ? body.launcher.trim() : undefined
312
332
  // parent = the spawning session's id, resolved by the CALLER (createSession) in its own process and passed
313
333
  // through here ([[session-nesting]]); the browser's New Session omits it → a top-level session.
314
334
  const parent = typeof body?.parent === 'string' && body.parent.trim() ? body.parent.trim() : null
315
335
  try {
316
- return c.json(await newSession(typeof body?.node === 'string' ? body.node : null, prompt, harness, parent, launcher), 201)
317
- } catch (e) { return c.json({ error: String((e as Error).message || e) }, 400) } // unknown harness/launcher id → 400, not a 500
336
+ return c.json(await newSession(typeof body?.node === 'string' ? body.node : null, prompt, parent, launcher), 201)
337
+ } catch (e) { return c.json({ error: String((e as Error).message || e) }, 400) } // unknown launcher id → 400, not a 500
318
338
  })
319
339
  // one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
320
340
  // dashboard and `spex review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
@@ -10,7 +10,7 @@
10
10
  import type { ForgeIssue, ForgePR } from '../../spec-forge/src/port.js'
11
11
  import { resolveLinks } from '../../spec-forge/src/links.js'
12
12
  import { DEFAULT_FORGE_HOST, FORGE_DRIVERS, forgeDriverFor, forgeIssueStores } from '../../spec-forge/src/drivers.js'
13
- import { loadLocalIssues, loadOne, postLocalIssue, reply, resolve, issuesEnabled, replyLocalIssue, runIssueWrite, ISSUE_WRITE_SUBS } from './localIssues.js'
13
+ import { closeLocalIssue, loadLocalIssues, loadOne, postLocalIssue, reply, issuesEnabled, replyLocalIssue, runIssueWrite, ISSUE_WRITE_SUBS } from './localIssues.js'
14
14
  import { dispatchMentions, parseMentions, type DispatchOutcome, type LoopIn } from './mentions.js'
15
15
  import { envSessionId } from './layout.js'
16
16
  import { loadSpecsLite } from './specs.js'
@@ -36,9 +36,8 @@ export type Issue = {
36
36
  store: string // 'local' | a forge host ('github') — the adapter that holds it
37
37
  concern: string
38
38
  by: string
39
- status: string // its own lifecycle: local open|accepted|rejected|landed; forge open|closed
39
+ status: string // its own lifecycle: local open|landed; forge open|closed
40
40
  nodes: string[]
41
- signers: string[]
42
41
  created: string
43
42
  body: string
44
43
  replies: Reply[]
@@ -122,7 +121,6 @@ export function fromForge(slice: ForgeSlice, nodeIds: string[]): Issue[] {
122
121
  by: i.author,
123
122
  status: (i.state || '').toLowerCase(),
124
123
  nodes: nodesByNumber.get(i.number) ?? [],
125
- signers: [],
126
124
  created: i.createdAt,
127
125
  body: i.body,
128
126
  // the forge comments ARE the thread — the same Reply shape a local thread carries, so nothing
@@ -148,10 +146,15 @@ export function mergedIssues(forge: ForgeSlice | null, nodeIds: string[]): Issue
148
146
  .sort((a, b) => b.created.localeCompare(a.created))
149
147
  }
150
148
 
149
+ // @@@ createIssue - the ONE creation port, store-routed ([[issues]]): the dashboard's New form
150
+ // (POST /api/issues) and `spex issues open [--store <store>]` run this SAME routine. Default local commits
151
+ // to the trunk store; a forge store creates the REAL forge issue through that store's driver, its body
152
+ // carrying the `Spec: <nodes>` marker so the existing tracer read links it straight back — no promote
153
+ // round-trip needed when the concern is born forge-visible.
151
154
  export async function createIssue(
152
155
  concern: string,
153
156
  opts: { store?: string; nodes?: string[]; body?: string; evidence?: string[]; author?: string } = {},
154
- ): Promise<{ store: string; id: string; url?: string; outcomes: DispatchOutcome[] }> {
157
+ ): Promise<{ store: string; id: string; nodes: string[]; url?: string; outcomes: DispatchOutcome[] }> {
155
158
  const store = opts.store || 'local'
156
159
  const author = opts.author || envSessionId() || 'unknown'
157
160
  if (store === 'local') {
@@ -161,7 +164,7 @@ export async function createIssue(
161
164
  evidence: opts.evidence,
162
165
  author,
163
166
  })
164
- return { store: 'local', id: thread.id, outcomes }
167
+ return { store: 'local', id: thread.id, nodes: thread.nodes, outcomes }
165
168
  }
166
169
 
167
170
  const driver = forgeDriverFor(store)
@@ -173,7 +176,7 @@ export async function createIssue(
173
176
  })
174
177
  const id = `${driver.host}#${number}`
175
178
  const outcomes = await dispatchMentions(opts.body || concern, { threadId: id, node: nodes[0] || null, author, status: 'open' })
176
- return { store: driver.host, id, url, outcomes }
179
+ return { store: driver.host, id, nodes, url, outcomes }
177
180
  }
178
181
 
179
182
  // @@@ promote - the ONE cross-store verb ([[issues]]): a local concern that outgrew the repo moves to the
@@ -181,9 +184,11 @@ export async function createIssue(
181
184
  // body + the `Spec: <nodes>` marker (the round-trip: the existing tracer read links it straight back to
182
185
  // the same nodes, no new linking code) + the evidence hashes + a provenance footer — and created through
183
186
  // the driver (the only network toucher). ORDER makes failure safe: create the forge issue FIRST; only
184
- // then close the local thread out (a reply carrying the permalink, then resolve `landed`) — an
187
+ // then close the local thread out (a reply carrying the permalink, then status `landed`) — an
185
188
  // unreachable forge throws with the local thread untouched, and only an `open` thread promotes.
186
- export async function promote(id: string): Promise<{ url: string; number: number; host: string }> {
189
+ // `author` mirrors the other write verbs: the effective session id by default, `'human'` from the dashboard.
190
+ export async function promote(id: string, opts: { author?: string } = {}): Promise<{ url: string; number: number; host: string }> {
191
+ const author = opts.author || envSessionId() || 'unknown'
187
192
  const t = loadOne(id)
188
193
  if (t.status !== 'open') throw new Error(`'${id}' is ${t.status} — only an open local issue promotes`)
189
194
  const driver = forgeDriverFor(DEFAULT_FORGE_HOST)
@@ -192,11 +197,11 @@ export async function promote(id: string): Promise<{ url: string; number: number
192
197
  t.body,
193
198
  t.nodes.length ? `\nSpec: ${t.nodes.join(', ')}` : '',
194
199
  t.evidence.length ? `\nEvidence: ${t.evidence.join(', ')} (yatsu blob hashes)` : '',
195
- `\n---\nPromoted from the local issue \`${id}\` (opened by ${t.by} @ ${t.created}; promoted by ${envSessionId() || 'unknown'}).`,
200
+ `\n---\nPromoted from the local issue \`${id}\` (opened by ${t.by} @ ${t.created}; promoted by ${author}).`,
196
201
  ].filter(Boolean).join('\n')
197
202
  const { number, url } = await driver.createIssue({ title: t.concern, body })
198
- reply(id, `promoted to the forge: ${url}`)
199
- resolve(id, 'landed')
203
+ reply(id, `promoted to the forge: ${url}`, author)
204
+ closeLocalIssue(id)
200
205
  return { url, number, host: driver.host }
201
206
  }
202
207
 
@@ -230,11 +235,11 @@ export async function replyIssue(
230
235
  }
231
236
 
232
237
  // @@@ closeIssue - ONE lifecycle close over every store ([[issues]]): the issue owns its status, so the
233
- // dashboard Close button routes by id and never writes node state. Local closes resolve the local thread
238
+ // dashboard Close button routes by id and never writes node state. Local closes mark the local thread
234
239
  // `landed`; forge closes call the driver's close verb and let the forced read-back reveal the closed state.
235
240
  export async function closeIssue(id: string): Promise<{ store: string; status: string; url?: string }> {
236
241
  const forge = /^([A-Za-z0-9-]+)#(\d+)$/.exec(id)
237
- if (!forge) return { store: 'local', status: resolve(id, 'landed').as } // resolve() returns {as, already} since the idempotent-write change; status stays the plain string
242
+ if (!forge) return { store: 'local', status: closeLocalIssue(id).status }
238
243
  const driver = forgeDriverFor(forge[1])
239
244
  if (!driver) throw new Error(`unknown forge host '${forge[1]}' — known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')}`)
240
245
  const { url } = await driver.closeIssue({ number: parseInt(forge[2], 10) })
@@ -250,25 +255,48 @@ const hasFlag = (args: string[], name: string) => args.includes(`--${name}`)
250
255
 
251
256
  // `spex issues …` — the ONE issues surface. Bare (with filters) it is THE read over every store: the
252
257
  // drain view a supervisor/human works from, `[--node id] [--store local|<host>] [--all] [--json]`. A write
253
- // first-positional (open|reply|sign|resolve|on|off|status|nudge — localIssues.ts) routes to the store's
254
- // write verbs; `promote` is the one cross-store verb. The list imposes NO salience ranking — recurrence
255
- // (signers, replies) is a signal the drain WEIGHS by judgment, never an automatic priority order. The forge
256
- // slice is a LIVE pull; an unreachable forge degrades loudly to local-only (one stderr note) local
257
- // reading never hostages on a network.
258
+ // first-positional (open|reply|on|off|status|nudge — localIssues.ts) routes to the store's
259
+ // write verbs (open and reply are themselves store-routed: `open --store <host>` / a `<host>#<n>` id go
260
+ // through the driver); `close` is the store-routed lifecycle verb (the SAME closeIssue the dashboard's
261
+ // Close button calls); `promote` is the one cross-store verb. The list imposes NO salience ranking
262
+ // replies are a signal the drain WEIGHS by judgment, never an automatic priority
263
+ // order. The forge slice is a LIVE pull; an unreachable forge degrades loudly to local-only (one stderr
264
+ // note) — local reading never hostages on a network.
258
265
  export async function runIssues(args: string[]): Promise<number> {
259
266
  if (ISSUE_WRITE_SUBS.has(args[0])) return runIssueWrite(args)
267
+ if (args[0] === 'close') {
268
+ // the CLI leg of the ONE close verb ([[issues]] closeIssue — the same routing POST /api/issues/:id/close
269
+ // runs): a local id resolves the thread `landed`, a forge id (`<host>#<n>`) closes the remote issue
270
+ // through the driver. Lifecycle on the issue object, never node state.
271
+ const id = args[1]
272
+ if (!id || id.startsWith('--')) { console.error('usage: spex issues close <issue-id> (a local id, or a forge id like github#12)'); return 2 }
273
+ try {
274
+ const r = await closeIssue(id)
275
+ console.log(r.store === 'local'
276
+ ? `closed '${id}' — local thread landed`
277
+ : `closed '${id}' on ${r.store}${r.url ? ` ${r.url}` : ''}`)
278
+ return 0
279
+ } catch (e) {
280
+ console.error(`spex issues close: ${e instanceof Error ? e.message : e}`)
281
+ return 1
282
+ }
283
+ }
260
284
  if (args[0] === 'promote') {
261
285
  const id = args[1]
262
286
  if (!id || id.startsWith('--')) { console.error('usage: spex issues promote <local-issue-id>'); return 2 }
263
287
  try {
264
288
  const r = await promote(id)
265
- console.log(`promoted '${id}' → ${r.host}#${r.number} ${r.url}\n local thread resolved landed (permalink recorded in its reply trail)`)
289
+ console.log(`promoted '${id}' → ${r.host}#${r.number} ${r.url}\n local thread closed landed (permalink recorded in its reply trail)`)
266
290
  return 0
267
291
  } catch (e) {
268
292
  console.error(`spex issues promote: ${e instanceof Error ? e.message : e}`)
269
293
  return 1
270
294
  }
271
295
  }
296
+ if (args[0] && !args[0].startsWith('--')) {
297
+ console.error(`spex issues: unknown subcommand '${args[0]}'`)
298
+ return 2
299
+ }
272
300
  const nodeIds = loadSpecsLite().map((s) => s.id)
273
301
  let forge: ForgeSlice | null = null
274
302
  try {
@@ -292,7 +320,6 @@ export async function runIssues(args: string[]): Promise<number> {
292
320
  const tags = [p.store, p.status !== 'open' ? `[${p.status}]` : '', p.nodes.length ? `re: ${p.nodes.join(', ')}` : '', p.by ? `by ${p.by}` : ''].filter(Boolean).join(' · ')
293
321
  console.log(`• ${p.concern} [${p.id}]`)
294
322
  console.log(` ${tags}`)
295
- if (p.signers.length) console.log(` +${p.signers.length} signed: ${p.signers.join(', ')}`)
296
323
  if (p.replies.length) console.log(` ${p.replies.length} reply(ies) in thread`)
297
324
  if (p.url) console.log(` ${p.url}`)
298
325
  }