spexcode 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -103
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +24 -1
- package/spec-cli/src/attach.ts +50 -0
- package/spec-cli/src/cli.ts +217 -64
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/guide.ts +79 -21
- package/spec-cli/src/harness.ts +53 -29
- package/spec-cli/src/help.ts +137 -49
- package/spec-cli/src/index.ts +31 -11
- package/spec-cli/src/issues.ts +48 -21
- package/spec-cli/src/layout.ts +3 -5
- package/spec-cli/src/lint.ts +34 -5
- package/spec-cli/src/localIssues.ts +44 -60
- package/spec-cli/src/materialize.ts +4 -2
- package/spec-cli/src/mentions.ts +22 -1
- package/spec-cli/src/pty-bridge.ts +39 -4
- package/spec-cli/src/ranker.ts +31 -12
- package/spec-cli/src/search.bench.mjs +30 -7
- package/spec-cli/src/search.ts +39 -0
- package/spec-cli/src/sessions.ts +160 -69
- package/spec-cli/src/specs.ts +16 -4
- package/spec-cli/src/supervise.ts +30 -6
- package/spec-cli/src/tree.ts +118 -0
- package/spec-cli/templates/hooks/post-merge +2 -2
- package/spec-cli/templates/hooks/pre-commit +34 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
- package/spec-cli/templates/spexcode.json +7 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-B5leylLf.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-RHNECU6x.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-YLD6IOmC.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +9 -0
- package/spec-dashboard/dist/assets/Settings-ZnOwskMZ.js +1 -0
- package/spec-dashboard/dist/assets/index-BdRQfrkR.js +41 -0
- package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-yatsu/src/cli.ts +128 -26
- package/spec-yatsu/src/evaltab.ts +7 -6
- package/spec-yatsu/src/filing.ts +6 -3
- package/spec-yatsu/src/proof.ts +10 -0
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-yatsu/src/sidecar.ts +25 -3
- package/spec-yatsu/src/timeline.ts +53 -23
- package/README.zh-CN.md +0 -135
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +0 -32
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +0 -145
package/spec-cli/src/help.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
[--
|
|
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,
|
|
93
|
-
the code.
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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>] [--
|
|
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
|
|
169
|
-
|
|
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.
|
|
202
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
|
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.
|
|
274
|
-
|
|
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
|
}
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
150
|
-
//
|
|
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
|
-
|
|
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
|
|
233
|
-
//
|
|
234
|
-
//
|
|
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
|
-
|
|
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,
|
|
317
|
-
} catch (e) { return c.json({ error: String((e as Error).message || e) }, 400) } // unknown
|
|
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]].
|
package/spec-cli/src/issues.ts
CHANGED
|
@@ -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,
|
|
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|
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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:
|
|
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|
|
|
254
|
-
// write verbs
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
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
|
|
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
|
}
|