spexcode 0.5.8 → 0.5.9
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/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { closeIssue, createIssue, findIssue, isRemark, mergedIssues, promote, type ForgeSlice, type Issue } from './issues.js'
|
|
3
|
+
import { FORGE_DRIVERS, forgeDriverFor, resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
4
|
+
import { currentSession, issuesEnabled, reply, resolveRemark, retractRemark } from './localIssues.js'
|
|
5
|
+
import { summarizeLoopIn } from './mentions.js'
|
|
6
|
+
import { loadSpecsLite } from './specs.js'
|
|
7
|
+
|
|
8
|
+
// @@@ issues-cli - the `spex issue` / `spex remark` CLI surface: argv parsing, console output, exit codes.
|
|
9
|
+
// It lives ABOVE the eval layer, which is the whole point. These handlers used to sit in `issues.ts` and
|
|
10
|
+
// `localIssues.ts` — modules the eval package imports — and a CLI surface is by definition the topmost layer,
|
|
11
|
+
// so hosting one down there gave those files two altitudes at once. That is what held the spec/eval package
|
|
12
|
+
// cycle: the loop-in's candidate resolution needs eval knowledge, its only callers were these handlers, and
|
|
13
|
+
// they could not reach eval from below it. Nothing here is new logic; it is the same surface at its own height.
|
|
14
|
+
//
|
|
15
|
+
// It is NOT merged into `cli.ts`: that file is the thin dispatch hub ([[cli-surface]]) whose eighty lazy
|
|
16
|
+
// `await import(...)` sites keep one invocation from loading every verb's module. Moving 265 lines of verb
|
|
17
|
+
// implementation there would have traded one two-altitude module for another. The hub gains one lazy line
|
|
18
|
+
// pointing here instead.
|
|
19
|
+
|
|
20
|
+
const fl = (args: string[], name: string): string | undefined => {
|
|
21
|
+
const i = args.indexOf(`--${name}`)
|
|
22
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
23
|
+
}
|
|
24
|
+
const hasFlag = (args: string[], name: string) => args.includes(`--${name}`)
|
|
25
|
+
|
|
26
|
+
// the CLI's live forge pull — `ls` and `show` own their freshness (a live driver read), degrading LOUDLY
|
|
27
|
+
// to local-only (one stderr note) when the forge is unreachable: local reading never hostages on a network.
|
|
28
|
+
async function liveForgeSlice(verb: string): Promise<ForgeSlice | null> {
|
|
29
|
+
try {
|
|
30
|
+
const host = resolveForgeHost()
|
|
31
|
+
const driver = forgeDriverFor(host)
|
|
32
|
+
if (!driver) throw new Error(`no driver for this repo's forge host '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')})`)
|
|
33
|
+
const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()])
|
|
34
|
+
return { host: driver.host, state: { issues, prs } }
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error(`spex issue ${verb}: forge unreachable — local only (${e instanceof Error ? e.message.split('\n')[0] : e})`)
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// the single-issue read behind `spex issue show` AND `GET /api/issues/:id` — find the thread in the SAME
|
|
42
|
+
// merged, eval-remark-free read every issue surface consumes (never a second lookup path: an eval-remark
|
|
43
|
+
// thread is not an issue, so `show` can't see one either). A local id needs no forge slice; a forge id
|
|
44
|
+
// (`<host>#<n>`) reads from the caller-supplied slice (live pull on the CLI, resident cache on the server).
|
|
45
|
+
|
|
46
|
+
function renderIssue(t: Issue): string {
|
|
47
|
+
const L: string[] = []
|
|
48
|
+
L.push(`${t.concern} [${t.id}]`)
|
|
49
|
+
L.push(` ${[t.store, t.status, t.nodes.length ? `re: ${t.nodes.join(', ')}` : '', t.by ? `by ${t.by}` : '', t.created].filter(Boolean).join(' · ')}`)
|
|
50
|
+
if (t.url) L.push(` ${t.url}`)
|
|
51
|
+
if (t.evidence.length) L.push(` evidence: ${t.evidence.join(', ')}`)
|
|
52
|
+
L.push('', t.body)
|
|
53
|
+
for (const r of t.replies) {
|
|
54
|
+
L.push('', `── ${isRemark(r) ? `remark ${t.id}#${r.rid}${r.resolved ? ` (resolved by ${r.resolvedBy})` : ' (unresolved)'}` : 'reply'}: ${r.by} @ ${r.at} ──`)
|
|
55
|
+
L.push(r.body)
|
|
56
|
+
}
|
|
57
|
+
return L.join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// `spex issue <verb>` — the ONE issue surface, a noun drawer ([[cli-surface]]). `ls` is THE read over
|
|
61
|
+
// every store: the drain view a supervisor/human works from, `[--node id] [--store local|<host>] [--all]
|
|
62
|
+
// [--json]`; `show <id>` is the single-thread detail (the same read GET /api/issues/:id serves). The
|
|
63
|
+
// write verbs (open|reply — localIssues.ts) are store-routed (`open --store <host>` / a `<host>#<n>` id
|
|
64
|
+
// go through the driver); `close` is the store-routed lifecycle verb (the SAME closeIssue the dashboard's
|
|
65
|
+
// Close button calls); `promote` is the one cross-store verb; `links` is the read-only forge→spec trace
|
|
66
|
+
// (spec-forge). The list imposes NO salience ranking — replies are a signal the drain WEIGHS by judgment,
|
|
67
|
+
// never an automatic priority order. The forge slice is a LIVE pull that degrades loudly to local-only.
|
|
68
|
+
// (`nudge` left this drawer for `spex internal nudge` — only the post-merge hook calls it; the old
|
|
69
|
+
// on|off|status toggle verbs are gone — the switch is the `issues.enabled` settings key.)
|
|
70
|
+
export async function runIssues(args: string[]): Promise<number> {
|
|
71
|
+
// the drawer's READ verbs (ls/show) surface a store failure exactly as the writes do
|
|
72
|
+
// ([[issues-store-rename]]'s both-exist teeth): one clean `spex issue: <message>` line + exit 1, never a
|
|
73
|
+
// raw stack — the message carries the repair, the stack is internals. The verbs that already catch with
|
|
74
|
+
// a more specific prefix (open/reply/close/promote) return before this guard ever sees their errors.
|
|
75
|
+
try { return await issueVerbs(args) }
|
|
76
|
+
catch (e) { console.error(`spex issue: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
77
|
+
}
|
|
78
|
+
async function issueVerbs(args: string[]): Promise<number> {
|
|
79
|
+
if (ISSUE_WRITE_SUBS.has(args[0])) return runIssueWrite(args)
|
|
80
|
+
if (args[0] === 'on' || args[0] === 'off' || args[0] === 'status') {
|
|
81
|
+
// v0.3.0 signpost — report the new home, never run ([[cli-surface]]: a removed spelling only points).
|
|
82
|
+
console.error(`spex: \`spex issue ${args[0]}\` was removed in v0.3.0 — the switch is the \`issues.enabled\` key in spexcode.json (edit the JSON; \`spex guide settings\` documents it, \`spex doctor\` reports its state)`)
|
|
83
|
+
return 2
|
|
84
|
+
}
|
|
85
|
+
if (args[0] === 'show') {
|
|
86
|
+
const id = args[1]
|
|
87
|
+
if (!id || id.startsWith('--')) { console.error('usage: spex issue show <issue-id> [--json] (a local id, or a forge id like github#12)'); return 2 }
|
|
88
|
+
const nodeIds = loadSpecsLite().map((s) => s.id)
|
|
89
|
+
const t = findIssue(id, id.includes('#') ? await liveForgeSlice('show') : null, nodeIds)
|
|
90
|
+
if (!t) { console.error(`spex issue show: no issue '${id}' (see \`spex issue ls --all\`)`); return 1 }
|
|
91
|
+
console.log(hasFlag(args, 'json') ? JSON.stringify(t, null, 2) : renderIssue(t))
|
|
92
|
+
return 0
|
|
93
|
+
}
|
|
94
|
+
if (args[0] === 'links') {
|
|
95
|
+
const { runIssueLinks } = await import('../../spec-forge/src/cli.js')
|
|
96
|
+
return runIssueLinks(args.slice(1))
|
|
97
|
+
}
|
|
98
|
+
if (args[0] === 'close') {
|
|
99
|
+
// the CLI leg of the ONE close verb ([[issues]] closeIssue — the same routing POST /api/issues/:id/close
|
|
100
|
+
// runs): a local id resolves the thread `landed`, a forge id (`<host>#<n>`) closes the remote issue
|
|
101
|
+
// through the driver. Lifecycle on the issue object, never node state.
|
|
102
|
+
const id = args[1]
|
|
103
|
+
if (!id || id.startsWith('--')) { console.error('usage: spex issue close <issue-id> (a local id, or a forge id like github#12)'); return 2 }
|
|
104
|
+
try {
|
|
105
|
+
const r = await closeIssue(id)
|
|
106
|
+
console.log(r.store === 'local'
|
|
107
|
+
? `closed '${id}' — local thread landed`
|
|
108
|
+
: `closed '${id}' on ${r.store}${r.url ? ` ${r.url}` : ''}`)
|
|
109
|
+
return 0
|
|
110
|
+
} catch (e) {
|
|
111
|
+
console.error(`spex issue close: ${e instanceof Error ? e.message : e}`)
|
|
112
|
+
return 1
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (args[0] === 'promote') {
|
|
116
|
+
const id = args[1]
|
|
117
|
+
if (!id || id.startsWith('--')) { console.error('usage: spex issue promote <local-issue-id>'); return 2 }
|
|
118
|
+
try {
|
|
119
|
+
const r = await promote(id)
|
|
120
|
+
console.log(`promoted '${id}' → ${r.host}#${r.number} ${r.url}\n local thread closed landed (permalink recorded in its reply trail)`)
|
|
121
|
+
return 0
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error(`spex issue promote: ${e instanceof Error ? e.message : e}`)
|
|
124
|
+
return 1
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (args[0] !== 'ls') {
|
|
128
|
+
console.error(`spex issue: unknown verb '${args[0]}' — ls | show | open | reply | close | promote | links (spex help issue)`)
|
|
129
|
+
return 2
|
|
130
|
+
}
|
|
131
|
+
args = args.slice(1)
|
|
132
|
+
const nodeIds = loadSpecsLite().map((s) => s.id)
|
|
133
|
+
const forge = await liveForgeSlice('ls')
|
|
134
|
+
let issues = mergedIssues(forge, nodeIds)
|
|
135
|
+
const node = fl(args, 'node')
|
|
136
|
+
const store = fl(args, 'store')
|
|
137
|
+
if (node) issues = issues.filter((p) => p.nodes.includes(node))
|
|
138
|
+
if (store) issues = issues.filter((p) => p.store === store)
|
|
139
|
+
if (!hasFlag(args, 'all')) issues = issues.filter((p) => p.status === 'open')
|
|
140
|
+
if (hasFlag(args, 'json')) { console.log(JSON.stringify(issues, null, 2)); return 0 }
|
|
141
|
+
if (!issues.length) { console.log(node ? `no issues for node '${node}'` : 'no open issues'); return 0 }
|
|
142
|
+
console.log(`issues — ${issues.length} ${hasFlag(args, 'all') ? 'total' : 'open'}${store ? ` in '${store}'` : ''}${node ? ` for '${node}'` : ''}\n`)
|
|
143
|
+
for (const p of issues) {
|
|
144
|
+
const tags = [p.store, p.status !== 'open' ? `[${p.status}]` : '', p.nodes.length ? `re: ${p.nodes.join(', ')}` : '', p.by ? `by ${p.by}` : ''].filter(Boolean).join(' · ')
|
|
145
|
+
console.log(`• ${p.concern} [${p.id}]`)
|
|
146
|
+
console.log(` ${tags}`)
|
|
147
|
+
if (p.replies.length) console.log(` ${p.replies.length} reply(ies) in thread`)
|
|
148
|
+
if (p.url) console.log(` ${p.url}`)
|
|
149
|
+
}
|
|
150
|
+
if (!issuesEnabled()) console.log('\n(the issues workflow is OFF — set `"issues": { "enabled": true }` in spexcode.json to re-enable writes/nudges)')
|
|
151
|
+
return 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const VALUE_FLAGS = new Set(['--node', '--body', '--evidence', '--scenario', '--code-sha', '--store'])
|
|
155
|
+
// bare positionals, skipping flags + their values.
|
|
156
|
+
function bare(args: string[]): string[] {
|
|
157
|
+
const out: string[] = []
|
|
158
|
+
for (let i = 0; i < args.length; i++) {
|
|
159
|
+
const t = args[i]
|
|
160
|
+
if (t.startsWith('--')) { if (VALUE_FLAGS.has(t)) i++; continue }
|
|
161
|
+
out.push(t)
|
|
162
|
+
}
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
// `--body -` reads stdin; `--body "text"` is literal; absent → undefined.
|
|
166
|
+
function readBody(args: string[]): string | undefined {
|
|
167
|
+
const v = fl(args, 'body')
|
|
168
|
+
if (v === undefined) return undefined
|
|
169
|
+
return v === '-' ? readFileSync(0, 'utf8') : v
|
|
170
|
+
}
|
|
171
|
+
// a repeatable value flag: every `--<name> <value>` pair, in order.
|
|
172
|
+
const repeated = (args: string[], name: string): string[] =>
|
|
173
|
+
args.flatMap((a, i) => (a === `--${name}` ? [args[i + 1]] : [])).filter(Boolean) as string[]
|
|
174
|
+
|
|
175
|
+
// the local-issue WRITE verbs of the issue drawer (`spex issue <verb>`): open "<concern>" [--store local|<host>] [--node id…]
|
|
176
|
+
// [--evidence hash…] [--body -|text], and the id-based reply. Store is a property of the issue,
|
|
177
|
+
// never a second command — open and reply route by it (issues.ts createIssue/replyIssue).
|
|
178
|
+
export async function runIssueWrite(args: string[]): Promise<number> {
|
|
179
|
+
const sub = args[0]
|
|
180
|
+
try {
|
|
181
|
+
if (sub === 'reply') {
|
|
182
|
+
const id = bare(args.slice(1))[0]
|
|
183
|
+
const body = readBody(args)
|
|
184
|
+
if (!id || !body) { console.error('usage: spex issue reply <issue-id> --body -|<text> [--evidence <hash>…]'); return 2 }
|
|
185
|
+
// the ONE store-routed reply verb ([[issues]]): a forge id posts a real comment through the driver,
|
|
186
|
+
// a local id commits to the store — the same command either way (dynamic import: no static cycle).
|
|
187
|
+
const r = await (await import('./loop-in.js')).replyIssueWithLoopIn(id, body, { evidence: repeated(args, 'evidence') })
|
|
188
|
+
console.log(r.store === 'local'
|
|
189
|
+
? `replied to '${id}' — ${r.replies?.length} post(s) in thread`
|
|
190
|
+
: `commented on '${id}' — ${r.url}`)
|
|
191
|
+
const s = summarizeLoopIn(r.loopIn)
|
|
192
|
+
if (s) console.log(` ${s}`)
|
|
193
|
+
return 0
|
|
194
|
+
}
|
|
195
|
+
// `open`: start a new issue — STORE-ROUTED through the one creation port ([[issues]] createIssue, the
|
|
196
|
+
// same routine POST /api/issues runs): default local commits to the trunk store; `--store <host>`
|
|
197
|
+
// creates the real forge issue through that store's driver (no promote round-trip when the concern is
|
|
198
|
+
// born forge-visible). The concern is the bare positional(s) after the sub.
|
|
199
|
+
const concern = sub === 'open' ? bare(args.slice(1)).join(' ').trim() : ''
|
|
200
|
+
if (!concern) {
|
|
201
|
+
console.error('usage: spex issue open "<concern>" [--store local|<host>] [--node <id>…] [--evidence <hash>…] [--body -|<text>]\n spex issue reply|close|promote <issue-id> …')
|
|
202
|
+
return 2
|
|
203
|
+
}
|
|
204
|
+
const r = await (await import('./issues.js')).createIssue(concern, {
|
|
205
|
+
store: fl(args, 'store'),
|
|
206
|
+
nodes: repeated(args, 'node'),
|
|
207
|
+
body: readBody(args),
|
|
208
|
+
evidence: repeated(args, 'evidence'),
|
|
209
|
+
})
|
|
210
|
+
const re = r.nodes.length ? ` (re: ${r.nodes.join(', ')})` : ''
|
|
211
|
+
console.log(r.store === 'local'
|
|
212
|
+
? `opened '${r.id}'${re} — committed to the local issue store; read it with \`spex issue ls\``
|
|
213
|
+
: `opened '${r.id}' on ${r.store}${re} — ${r.url}`)
|
|
214
|
+
return 0
|
|
215
|
+
} catch (e) {
|
|
216
|
+
console.error(`spex issue: ${e instanceof Error ? e.message : e}`)
|
|
217
|
+
return 1
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// the first positionals runIssueWrite handles — the issue drawer routes these to it. Exported so the
|
|
222
|
+
// router and the runner can never drift. (`nudge` is not here: it is machine plumbing, called only by the
|
|
223
|
+
// post-merge hook as `spex internal nudge`; the on|off|status toggle verbs died in v0.3.0 — the switch is
|
|
224
|
+
// the `issues.enabled` settings key.)
|
|
225
|
+
export const ISSUE_WRITE_SUBS = new Set(['open', 'reply'])
|
|
226
|
+
|
|
227
|
+
// ── remark CLI ([[remark-substrate]]) — CLI-first: the whole author→resolve→retract loop, no server needed ──
|
|
228
|
+
// `spex remark add <issue-id | <node> --scenario <name>> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]`
|
|
229
|
+
// host = a local issue id, OR a <node> with --scenario <name>. Records targetCodeSha (default: worktree HEAD).
|
|
230
|
+
export async function runRemark(args: string[]): Promise<number> {
|
|
231
|
+
try {
|
|
232
|
+
const scenario = fl(args, 'scenario')
|
|
233
|
+
const positional = bare(args)[0]
|
|
234
|
+
const body = readBody(args)
|
|
235
|
+
if (!positional || !body) {
|
|
236
|
+
console.error('usage: spex remark add <issue-id | node --scenario name> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]')
|
|
237
|
+
return 2
|
|
238
|
+
}
|
|
239
|
+
// THE FLAG DECIDES THE PARSE ([[cli-surface]] §1): `--scenario` present ⇒ the positional is a NODE id
|
|
240
|
+
// (the remark pins to that node's scenario track); absent ⇒ it is an ISSUE id. Never type-sniffed —
|
|
241
|
+
// a node id and an issue id are both bare slugs, so any "looks like" guess would misroute; the flag
|
|
242
|
+
// is the one unambiguous discriminator, and a wrong host fails loud downstream (unknown issue/node).
|
|
243
|
+
const host = scenario ? { node: positional, scenario } : { issue: positional }
|
|
244
|
+
const r = await (await import('./loop-in.js')).remarkWithLoopIn(host, body, { codeSha: fl(args, 'code-sha'), evidence: repeated(args, 'evidence') })
|
|
245
|
+
console.log(`remark ${r.ref} (against ${r.codeSha.slice(0, 7) || 'HEAD'}) — read it with \`spex issue ls --all\``)
|
|
246
|
+
const s = summarizeLoopIn(r.loopIn)
|
|
247
|
+
if (s) console.log(` ${s}`)
|
|
248
|
+
return 0
|
|
249
|
+
} catch (e) {
|
|
250
|
+
console.error(`spex remark add: ${e instanceof Error ? e.message : e}`)
|
|
251
|
+
return 1
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// `spex remark resolve <remark-ref>` — flip resolved=true (agent-only, never the author, monotonic — see resolveRemark).
|
|
256
|
+
export async function runResolve(args: string[]): Promise<number> {
|
|
257
|
+
const ref = bare(args)[0]
|
|
258
|
+
if (!ref) { console.error('usage: spex remark resolve <remark-ref> (the <thread-id>#<rid> `spex remark add` printed)'); return 2 }
|
|
259
|
+
try {
|
|
260
|
+
const by = currentSession()
|
|
261
|
+
resolveRemark(ref, by)
|
|
262
|
+
console.log(`resolved remark ${ref} — by ${by}`)
|
|
263
|
+
return 0
|
|
264
|
+
} catch (e) { console.error(`spex remark resolve: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// `spex remark retract <remark-ref>` — the author withdraws their OWN remark, removing it (author-only — see retractRemark).
|
|
268
|
+
export async function runRetract(args: string[]): Promise<number> {
|
|
269
|
+
const ref = bare(args)[0]
|
|
270
|
+
if (!ref) { console.error('usage: spex remark retract <remark-ref>'); return 2 }
|
|
271
|
+
try {
|
|
272
|
+
retractRemark(ref, currentSession())
|
|
273
|
+
console.log(`retracted remark ${ref}`)
|
|
274
|
+
return 0
|
|
275
|
+
} catch (e) { console.error(`spex remark retract: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
276
|
+
}
|
package/spec-cli/src/issues.ts
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
// @@@ issues - ONE Issue object over every store ([[issues]]). An Issue is a recorded concern bound to
|
|
2
|
-
// spec node(s), carrying its OWN lifecycle, living beside the graph and never as node state. WHERE it is
|
|
3
|
-
// stored — the local git store ([[local-issues]]) or a remote forge (spec-forge) — is a per-issue property
|
|
4
|
-
// (`store`), not a project mode: a project holds both at once, mixed. This module owns the core type, the
|
|
5
|
-
// forge→Issue translation (the ONLY place a host's node-naming conventions become `nodes[]` — platform
|
|
6
|
-
// differences stay at the adapter boundary), the merged read every surface consumes (CLI `spex issue ls`,
|
|
7
|
-
// GET /api/issues, the board fold), the STORE-ROUTED reply/close verbs, and the CLI itself. Content writes are
|
|
8
|
-
// owned per store: local ones live in localIssues.ts; a forge write goes through the driver's write verbs
|
|
9
|
-
// (createIssue/createComment/closeIssue — the driver stays the only network toucher; the tracer stays read-only).
|
|
10
1
|
import type { ForgeIssue, ForgePR } from '../../spec-forge/src/port.js'
|
|
11
2
|
import { resolveLinks } from '../../spec-forge/src/links.js'
|
|
12
3
|
import { FORGE_DRIVERS, forgeDriverFor, forgeIssueStores, resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
13
|
-
import { closeLocalIssue, loadLocalIssues, loadOne, postLocalIssue, reply, issuesEnabled, replyLocalIssue,
|
|
14
|
-
import {
|
|
4
|
+
import { closeLocalIssue, loadLocalIssues, loadOne, postLocalIssue, reply, issuesEnabled, replyLocalIssue, parseEvalConcern } from './localIssues.js'
|
|
5
|
+
import { parseMentions, type LoopIn } from './mentions.js'
|
|
15
6
|
import { envSessionId } from './layout.js'
|
|
16
7
|
import { loadSpecsLite } from './specs.js'
|
|
17
8
|
|
|
@@ -79,14 +70,13 @@ export type RemarkTrack = { threadId: string; node: string; scenario: string; th
|
|
|
79
70
|
|
|
80
71
|
// `eval: <node> · <scenario>` — node first (never contains ' · '), then the scenario (may). One thread per
|
|
81
72
|
// pair (EventDetail.jsx evalConcern / localIssues.ts resolveRemarkHost mint it), so the last write wins is fine.
|
|
82
|
-
const EVAL_CONCERN_RE = /^eval: (.+?) · (.+)$/
|
|
83
73
|
export const trackKey = (node: string, scenario: string): string => `${node} · ${scenario}`
|
|
84
74
|
|
|
85
75
|
// an eval-remark thread is the eval scoreboard's data, NOT a drain-worthy issue (I1: a scenario-scoped
|
|
86
76
|
// concern is a remark, never an issue). Its `eval: <node> · <scenario>` concern is the tell — the SAME key
|
|
87
77
|
// loadEvalRemarkTracks isolates them by. The two reads are complementary over one store: mergedIssues (the
|
|
88
78
|
// ISSUE surfaces) excludes these; loadEvalRemarkTracks (the EVAL surfaces) keeps only these.
|
|
89
|
-
export const isEvalConcern = (concern: string): boolean =>
|
|
79
|
+
export const isEvalConcern = (concern: string): boolean => !!parseEvalConcern(concern)
|
|
90
80
|
|
|
91
81
|
// read the whole local store ONCE and split the eval-concern threads out (directive 3): trunk-scoped,
|
|
92
82
|
// read-time, no branch write. A remark whose scenario no longer exists still LOADS here (it just keys a pair
|
|
@@ -94,9 +84,9 @@ export const isEvalConcern = (concern: string): boolean => EVAL_CONCERN_RE.test(
|
|
|
94
84
|
export function loadEvalRemarkTracks(): Map<string, RemarkTrack> {
|
|
95
85
|
const out = new Map<string, RemarkTrack>()
|
|
96
86
|
for (const t of loadLocalIssues()) {
|
|
97
|
-
const
|
|
98
|
-
if (!
|
|
99
|
-
const node
|
|
87
|
+
const parsed = parseEvalConcern(t.concern)
|
|
88
|
+
if (!parsed) continue
|
|
89
|
+
const { node, scenario } = parsed
|
|
100
90
|
out.set(trackKey(node, scenario), { threadId: t.id, node, scenario, thread: t, remarks: t.replies.filter(isRemark) })
|
|
101
91
|
}
|
|
102
92
|
return out
|
|
@@ -152,24 +142,11 @@ function allThreads(forge: ForgeSlice | null, nodeIds: string[]): Issue[] {
|
|
|
152
142
|
return [...loadLocalIssues(), ...remote].sort((a, b) => b.created.localeCompare(a.created))
|
|
153
143
|
}
|
|
154
144
|
|
|
155
|
-
// @@@ boardThreads - the board's ONE store read ([[graph-lean]]'s fold and [[remark-substrate]]'s carrier
|
|
156
|
-
// from one walk). It hands back the SPLIT issue population every issue surface consumes and the freshness
|
|
157
|
-
// stamp folded over the WHOLE store, together, because the two answer different questions and must not be
|
|
158
|
-
// derived from each other: the split is a SURFACE question (which page renders this thread), the stamp is a
|
|
159
|
-
// STORE question (was anything written at all). Folding the stamp from whatever the surface read returned
|
|
160
|
-
// is exactly the confusion that left an open reading blind to every remark on it — a scenario-hosted remark
|
|
161
|
-
// moved no board byte, so no push ever fired. Pairing them here means no caller can pick the wrong set.
|
|
162
145
|
export function boardThreads(forge: ForgeSlice | null, nodeIds: string[]): { issues: Issue[]; stamp: string } {
|
|
163
146
|
const threads = allThreads(forge, nodeIds)
|
|
164
147
|
return { issues: threads.filter((i) => !isEvalConcern(i.concern)), stamp: threadStamp(threads) }
|
|
165
148
|
}
|
|
166
149
|
|
|
167
|
-
// @@@ threadStamp - the board's freshness carrier over a thread set ([[remark-substrate]] write-visibility):
|
|
168
|
-
// open-count : thread-count : reply-count : latest-activity. Every thread write — open, reply, remark,
|
|
169
|
-
// resolve, retract, close — moves at least one component, so a store write ALWAYS moves board bytes and
|
|
170
|
-
// [[graph-delta]]'s no-change suppression can never swallow the push. Pure over the set it is handed, which
|
|
171
|
-
// is what makes "does THIS write move it" a test rather than a claim; the caller owes it the WHOLE store
|
|
172
|
-
// (allThreads), because a set missing a half is a carrier blind to that half.
|
|
173
150
|
export function threadStamp(threads: Issue[]): string {
|
|
174
151
|
return [
|
|
175
152
|
threads.filter((i) => i.status === 'open').length,
|
|
@@ -179,25 +156,20 @@ export function threadStamp(threads: Issue[]): string {
|
|
|
179
156
|
].join(':')
|
|
180
157
|
}
|
|
181
158
|
|
|
182
|
-
// @@@ createIssue - the ONE creation port, store-routed ([[issues]]): the dashboard's New form
|
|
183
|
-
// (POST /api/issues) and `spex issue open [--store <store>]` run this SAME routine. Default local commits
|
|
184
|
-
// to the trunk store; a forge store creates the REAL forge issue through that store's driver, its body
|
|
185
|
-
// carrying the `Spec: <nodes>` marker so the existing tracer read links it straight back — no promote
|
|
186
|
-
// round-trip needed when the concern is born forge-visible.
|
|
187
159
|
export async function createIssue(
|
|
188
160
|
concern: string,
|
|
189
161
|
opts: { store?: string; nodes?: string[]; body?: string; evidence?: string[]; author?: string } = {},
|
|
190
|
-
): Promise<{ store: string; id: string; nodes: string[]; url?: string
|
|
162
|
+
): Promise<{ store: string; id: string; nodes: string[]; url?: string }> {
|
|
191
163
|
const store = opts.store || 'local'
|
|
192
164
|
const author = opts.author || envSessionId() || 'unknown'
|
|
193
165
|
if (store === 'local') {
|
|
194
|
-
const { thread
|
|
166
|
+
const { thread } = await postLocalIssue(concern, {
|
|
195
167
|
nodes: opts.nodes,
|
|
196
168
|
body: opts.body,
|
|
197
169
|
evidence: opts.evidence,
|
|
198
170
|
author,
|
|
199
171
|
})
|
|
200
|
-
return { store: 'local', id: thread.id, nodes: thread.nodes
|
|
172
|
+
return { store: 'local', id: thread.id, nodes: thread.nodes }
|
|
201
173
|
}
|
|
202
174
|
|
|
203
175
|
const driver = forgeDriverFor(store)
|
|
@@ -207,19 +179,9 @@ export async function createIssue(
|
|
|
207
179
|
title: concern,
|
|
208
180
|
body: forgeIssueBody(concern, opts.body, nodes, opts.evidence),
|
|
209
181
|
})
|
|
210
|
-
|
|
211
|
-
const outcomes = await dispatchMentions(opts.body || concern, { threadId: id, node: nodes[0] || null, author, status: 'open' })
|
|
212
|
-
return { store: driver.host, id, nodes, url, outcomes }
|
|
182
|
+
return { store: driver.host, id: `${driver.host}#${number}`, nodes, url }
|
|
213
183
|
}
|
|
214
184
|
|
|
215
|
-
// @@@ promote - the ONE cross-store verb ([[issues]]): a local concern that outgrew the repo moves to the
|
|
216
|
-
// forge as one recorded action. The forge issue is composed from the thread itself — concern → title;
|
|
217
|
-
// body + the `Spec: <nodes>` marker (the round-trip: the existing tracer read links it straight back to
|
|
218
|
-
// the same nodes, no new linking code) + the evidence hashes + a provenance footer — and created through
|
|
219
|
-
// the driver (the only network toucher). ORDER makes failure safe: create the forge issue FIRST; only
|
|
220
|
-
// then close the local thread out (a reply carrying the permalink, then status `landed`) — an
|
|
221
|
-
// unreachable forge throws with the local thread untouched, and only an `open` thread promotes.
|
|
222
|
-
// `author` mirrors the other write verbs: the effective session id by default, `'human'` from the dashboard.
|
|
223
185
|
export async function promote(id: string, opts: { author?: string } = {}): Promise<{ url: string; number: number; host: string }> {
|
|
224
186
|
const author = opts.author || envSessionId() || 'unknown'
|
|
225
187
|
const t = loadOne(id)
|
|
@@ -239,38 +201,26 @@ export async function promote(id: string, opts: { author?: string } = {}): Promi
|
|
|
239
201
|
return { url, number, host: driver.host }
|
|
240
202
|
}
|
|
241
203
|
|
|
242
|
-
// @@@ replyIssue - ONE reply verb, store-routed ([[issues]]): store is a property of the issue, so
|
|
243
|
-
// replying doesn't fork by surface — a local id goes through the store's committed write (localIssues.ts,
|
|
244
|
-
// unchanged), a forge id (`<host>#<n>`) posts a REAL comment through the driver's createComment (the same
|
|
245
|
-
// seam discipline as promotion — no second network call-site). Either way the reply TEXT then dispatches
|
|
246
|
-
// its @-mentions (mentions.ts is store-agnostic: the mention fires on the words, and the mention IS the
|
|
247
|
-
// assign — no separate assign machinery). Callers own freshness: the server refreshes its resident forge
|
|
248
|
-
// slice after a forge write; the CLI's next read is a live pull anyway.
|
|
249
204
|
export async function replyIssue(
|
|
250
205
|
id: string,
|
|
251
206
|
body: string,
|
|
252
207
|
opts: { author?: string; node?: string | null; evidence?: string[] } = {},
|
|
253
|
-
): Promise<{ store: string; replies?: Reply[]; url?: string;
|
|
208
|
+
): Promise<{ store: string; replies?: Reply[]; url?: string; thread?: Issue; author: string }> {
|
|
254
209
|
const author = opts.author || envSessionId() || 'unknown'
|
|
255
210
|
const forge = /^([A-Za-z0-9-]+)#(\d+)$/.exec(id)
|
|
256
211
|
if (!forge) {
|
|
257
212
|
// evidence hashes accrue onto the local thread's typed evidence[] (a forge thread has no such field —
|
|
258
213
|
// an annotation's frame rides its comment body's image link there, the driver the only network toucher);
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return { store: 'local', replies: thread.replies,
|
|
214
|
+
const { thread } = await replyLocalIssue(id, body, author, opts.evidence)
|
|
215
|
+
// the thread rides along so [[loop-in]] can resolve this reply's originator chain without a second read.
|
|
216
|
+
return { store: 'local', replies: thread.replies, thread, author }
|
|
262
217
|
}
|
|
263
218
|
const driver = forgeDriverFor(forge[1])
|
|
264
219
|
if (!driver) throw new Error(`unknown forge host '${forge[1]}' — known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')}`)
|
|
265
220
|
const { url } = await driver.createComment({ number: parseInt(forge[2], 10), body })
|
|
266
|
-
|
|
267
|
-
// a forge issue's author is a github login, not a live session → no reachable originator to loop in (silent).
|
|
268
|
-
return { store: forge[1], url, outcomes, loopIn: null }
|
|
221
|
+
return { store: forge[1], url, author }
|
|
269
222
|
}
|
|
270
223
|
|
|
271
|
-
// @@@ closeIssue - ONE lifecycle close over every store ([[issues]]): the issue owns its status, so the
|
|
272
|
-
// dashboard Close button routes by id and never writes node state. Local closes mark the local thread
|
|
273
|
-
// `landed`; forge closes call the driver's close verb and let the forced read-back reveal the closed state.
|
|
274
224
|
export async function closeIssue(id: string): Promise<{ store: string; status: string; url?: string }> {
|
|
275
225
|
const forge = /^([A-Za-z0-9-]+)#(\d+)$/.exec(id)
|
|
276
226
|
if (!forge) return { store: 'local', status: closeLocalIssue(id).status }
|
|
@@ -281,139 +231,7 @@ export async function closeIssue(id: string): Promise<{ store: string; status: s
|
|
|
281
231
|
}
|
|
282
232
|
|
|
283
233
|
// ───────────────────────── CLI ─────────────────────────
|
|
284
|
-
const fl = (args: string[], name: string): string | undefined => {
|
|
285
|
-
const i = args.indexOf(`--${name}`)
|
|
286
|
-
return i >= 0 ? args[i + 1] : undefined
|
|
287
|
-
}
|
|
288
|
-
const hasFlag = (args: string[], name: string) => args.includes(`--${name}`)
|
|
289
234
|
|
|
290
|
-
// the CLI's live forge pull — `ls` and `show` own their freshness (a live driver read), degrading LOUDLY
|
|
291
|
-
// to local-only (one stderr note) when the forge is unreachable: local reading never hostages on a network.
|
|
292
|
-
async function liveForgeSlice(verb: string): Promise<ForgeSlice | null> {
|
|
293
|
-
try {
|
|
294
|
-
const host = resolveForgeHost()
|
|
295
|
-
const driver = forgeDriverFor(host)
|
|
296
|
-
if (!driver) throw new Error(`no driver for this repo's forge host '${host}' (known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')})`)
|
|
297
|
-
const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()])
|
|
298
|
-
return { host: driver.host, state: { issues, prs } }
|
|
299
|
-
} catch (e) {
|
|
300
|
-
console.error(`spex issue ${verb}: forge unreachable — local only (${e instanceof Error ? e.message.split('\n')[0] : e})`)
|
|
301
|
-
return null
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// the single-issue read behind `spex issue show` AND `GET /api/issues/:id` — find the thread in the SAME
|
|
306
|
-
// merged, eval-remark-free read every issue surface consumes (never a second lookup path: an eval-remark
|
|
307
|
-
// thread is not an issue, so `show` can't see one either). A local id needs no forge slice; a forge id
|
|
308
|
-
// (`<host>#<n>`) reads from the caller-supplied slice (live pull on the CLI, resident cache on the server).
|
|
309
235
|
export function findIssue(id: string, forge: ForgeSlice | null, nodeIds: string[]): Issue | undefined {
|
|
310
236
|
return mergedIssues(id.includes('#') ? forge : null, nodeIds).find((i) => i.id === id)
|
|
311
237
|
}
|
|
312
|
-
|
|
313
|
-
function renderIssue(t: Issue): string {
|
|
314
|
-
const L: string[] = []
|
|
315
|
-
L.push(`${t.concern} [${t.id}]`)
|
|
316
|
-
L.push(` ${[t.store, t.status, t.nodes.length ? `re: ${t.nodes.join(', ')}` : '', t.by ? `by ${t.by}` : '', t.created].filter(Boolean).join(' · ')}`)
|
|
317
|
-
if (t.url) L.push(` ${t.url}`)
|
|
318
|
-
if (t.evidence.length) L.push(` evidence: ${t.evidence.join(', ')}`)
|
|
319
|
-
L.push('', t.body)
|
|
320
|
-
for (const r of t.replies) {
|
|
321
|
-
L.push('', `── ${isRemark(r) ? `remark ${t.id}#${r.rid}${r.resolved ? ` (resolved by ${r.resolvedBy})` : ' (unresolved)'}` : 'reply'}: ${r.by} @ ${r.at} ──`)
|
|
322
|
-
L.push(r.body)
|
|
323
|
-
}
|
|
324
|
-
return L.join('\n')
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// `spex issue <verb>` — the ONE issue surface, a noun drawer ([[cli-surface]]). `ls` is THE read over
|
|
328
|
-
// every store: the drain view a supervisor/human works from, `[--node id] [--store local|<host>] [--all]
|
|
329
|
-
// [--json]`; `show <id>` is the single-thread detail (the same read GET /api/issues/:id serves). The
|
|
330
|
-
// write verbs (open|reply — localIssues.ts) are store-routed (`open --store <host>` / a `<host>#<n>` id
|
|
331
|
-
// go through the driver); `close` is the store-routed lifecycle verb (the SAME closeIssue the dashboard's
|
|
332
|
-
// Close button calls); `promote` is the one cross-store verb; `links` is the read-only forge→spec trace
|
|
333
|
-
// (spec-forge). The list imposes NO salience ranking — replies are a signal the drain WEIGHS by judgment,
|
|
334
|
-
// never an automatic priority order. The forge slice is a LIVE pull that degrades loudly to local-only.
|
|
335
|
-
// (`nudge` left this drawer for `spex internal nudge` — only the post-merge hook calls it; the old
|
|
336
|
-
// on|off|status toggle verbs are gone — the switch is the `issues.enabled` settings key.)
|
|
337
|
-
export async function runIssues(args: string[]): Promise<number> {
|
|
338
|
-
// the drawer's READ verbs (ls/show) surface a store failure exactly as the writes do
|
|
339
|
-
// ([[issues-store-rename]]'s both-exist teeth): one clean `spex issue: <message>` line + exit 1, never a
|
|
340
|
-
// raw stack — the message carries the repair, the stack is internals. The verbs that already catch with
|
|
341
|
-
// a more specific prefix (open/reply/close/promote) return before this guard ever sees their errors.
|
|
342
|
-
try { return await issueVerbs(args) }
|
|
343
|
-
catch (e) { console.error(`spex issue: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
344
|
-
}
|
|
345
|
-
async function issueVerbs(args: string[]): Promise<number> {
|
|
346
|
-
if (ISSUE_WRITE_SUBS.has(args[0])) return runIssueWrite(args)
|
|
347
|
-
if (args[0] === 'on' || args[0] === 'off' || args[0] === 'status') {
|
|
348
|
-
// v0.3.0 signpost — report the new home, never run ([[cli-surface]]: a removed spelling only points).
|
|
349
|
-
console.error(`spex: \`spex issue ${args[0]}\` was removed in v0.3.0 — the switch is the \`issues.enabled\` key in spexcode.json (edit the JSON; \`spex guide settings\` documents it, \`spex doctor\` reports its state)`)
|
|
350
|
-
return 2
|
|
351
|
-
}
|
|
352
|
-
if (args[0] === 'show') {
|
|
353
|
-
const id = args[1]
|
|
354
|
-
if (!id || id.startsWith('--')) { console.error('usage: spex issue show <issue-id> [--json] (a local id, or a forge id like github#12)'); return 2 }
|
|
355
|
-
const nodeIds = loadSpecsLite().map((s) => s.id)
|
|
356
|
-
const t = findIssue(id, id.includes('#') ? await liveForgeSlice('show') : null, nodeIds)
|
|
357
|
-
if (!t) { console.error(`spex issue show: no issue '${id}' (see \`spex issue ls --all\`)`); return 1 }
|
|
358
|
-
console.log(hasFlag(args, 'json') ? JSON.stringify(t, null, 2) : renderIssue(t))
|
|
359
|
-
return 0
|
|
360
|
-
}
|
|
361
|
-
if (args[0] === 'links') {
|
|
362
|
-
const { runIssueLinks } = await import('../../spec-forge/src/cli.js')
|
|
363
|
-
return runIssueLinks(args.slice(1))
|
|
364
|
-
}
|
|
365
|
-
if (args[0] === 'close') {
|
|
366
|
-
// the CLI leg of the ONE close verb ([[issues]] closeIssue — the same routing POST /api/issues/:id/close
|
|
367
|
-
// runs): a local id resolves the thread `landed`, a forge id (`<host>#<n>`) closes the remote issue
|
|
368
|
-
// through the driver. Lifecycle on the issue object, never node state.
|
|
369
|
-
const id = args[1]
|
|
370
|
-
if (!id || id.startsWith('--')) { console.error('usage: spex issue close <issue-id> (a local id, or a forge id like github#12)'); return 2 }
|
|
371
|
-
try {
|
|
372
|
-
const r = await closeIssue(id)
|
|
373
|
-
console.log(r.store === 'local'
|
|
374
|
-
? `closed '${id}' — local thread landed`
|
|
375
|
-
: `closed '${id}' on ${r.store}${r.url ? ` ${r.url}` : ''}`)
|
|
376
|
-
return 0
|
|
377
|
-
} catch (e) {
|
|
378
|
-
console.error(`spex issue close: ${e instanceof Error ? e.message : e}`)
|
|
379
|
-
return 1
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
if (args[0] === 'promote') {
|
|
383
|
-
const id = args[1]
|
|
384
|
-
if (!id || id.startsWith('--')) { console.error('usage: spex issue promote <local-issue-id>'); return 2 }
|
|
385
|
-
try {
|
|
386
|
-
const r = await promote(id)
|
|
387
|
-
console.log(`promoted '${id}' → ${r.host}#${r.number} ${r.url}\n local thread closed landed (permalink recorded in its reply trail)`)
|
|
388
|
-
return 0
|
|
389
|
-
} catch (e) {
|
|
390
|
-
console.error(`spex issue promote: ${e instanceof Error ? e.message : e}`)
|
|
391
|
-
return 1
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
if (args[0] !== 'ls') {
|
|
395
|
-
console.error(`spex issue: unknown verb '${args[0]}' — ls | show | open | reply | close | promote | links (spex help issue)`)
|
|
396
|
-
return 2
|
|
397
|
-
}
|
|
398
|
-
args = args.slice(1)
|
|
399
|
-
const nodeIds = loadSpecsLite().map((s) => s.id)
|
|
400
|
-
const forge = await liveForgeSlice('ls')
|
|
401
|
-
let issues = mergedIssues(forge, nodeIds)
|
|
402
|
-
const node = fl(args, 'node')
|
|
403
|
-
const store = fl(args, 'store')
|
|
404
|
-
if (node) issues = issues.filter((p) => p.nodes.includes(node))
|
|
405
|
-
if (store) issues = issues.filter((p) => p.store === store)
|
|
406
|
-
if (!hasFlag(args, 'all')) issues = issues.filter((p) => p.status === 'open')
|
|
407
|
-
if (hasFlag(args, 'json')) { console.log(JSON.stringify(issues, null, 2)); return 0 }
|
|
408
|
-
if (!issues.length) { console.log(node ? `no issues for node '${node}'` : 'no open issues'); return 0 }
|
|
409
|
-
console.log(`issues — ${issues.length} ${hasFlag(args, 'all') ? 'total' : 'open'}${store ? ` in '${store}'` : ''}${node ? ` for '${node}'` : ''}\n`)
|
|
410
|
-
for (const p of issues) {
|
|
411
|
-
const tags = [p.store, p.status !== 'open' ? `[${p.status}]` : '', p.nodes.length ? `re: ${p.nodes.join(', ')}` : '', p.by ? `by ${p.by}` : ''].filter(Boolean).join(' · ')
|
|
412
|
-
console.log(`• ${p.concern} [${p.id}]`)
|
|
413
|
-
console.log(` ${tags}`)
|
|
414
|
-
if (p.replies.length) console.log(` ${p.replies.length} reply(ies) in thread`)
|
|
415
|
-
if (p.url) console.log(` ${p.url}`)
|
|
416
|
-
}
|
|
417
|
-
if (!issuesEnabled()) console.log('\n(the issues workflow is OFF — set `"issues": { "enabled": true }` in spexcode.json to re-enable writes/nudges)')
|
|
418
|
-
return 0
|
|
419
|
-
}
|