spexcode 0.5.8 → 0.6.0

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 (104) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +122 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/delivery-queue.ts +107 -0
  15. package/spec-cli/src/doctor.ts +0 -16
  16. package/spec-cli/src/gateway-auth.ts +0 -13
  17. package/spec-cli/src/gateway.ts +0 -22
  18. package/spec-cli/src/git.ts +151 -48
  19. package/spec-cli/src/graph.ts +28 -7
  20. package/spec-cli/src/graphCache.ts +253 -72
  21. package/spec-cli/src/graphDelta.ts +0 -13
  22. package/spec-cli/src/graphStream.ts +70 -37
  23. package/spec-cli/src/guide.ts +29 -6
  24. package/spec-cli/src/harness.ts +232 -248
  25. package/spec-cli/src/help.ts +27 -26
  26. package/spec-cli/src/host-resources.ts +11 -2
  27. package/spec-cli/src/index.ts +71 -95
  28. package/spec-cli/src/issues-cli.ts +276 -0
  29. package/spec-cli/src/issues.ts +15 -197
  30. package/spec-cli/src/layout.ts +78 -2
  31. package/spec-cli/src/lint.ts +5 -3
  32. package/spec-cli/src/localIssues.ts +22 -185
  33. package/spec-cli/src/loop-in.ts +78 -0
  34. package/spec-cli/src/materialize.ts +0 -42
  35. package/spec-cli/src/mentions.ts +49 -193
  36. package/spec-cli/src/opencode-headless.ts +7 -7
  37. package/spec-cli/src/opencode.ts +2 -5
  38. package/spec-cli/src/pi-headless.ts +5 -5
  39. package/spec-cli/src/pty-bridge.ts +4 -5
  40. package/spec-cli/src/reviewSnapshot.ts +1 -0
  41. package/spec-cli/src/reviews.ts +6 -1
  42. package/spec-cli/src/root-lru.ts +54 -0
  43. package/spec-cli/src/runtime-guard.ts +0 -9
  44. package/spec-cli/src/session-cursors.ts +91 -0
  45. package/spec-cli/src/session-follow.ts +182 -0
  46. package/spec-cli/src/session-timeline.ts +55 -127
  47. package/spec-cli/src/sessions.ts +235 -1021
  48. package/spec-cli/src/shim-runtime.ts +9 -16
  49. package/spec-cli/src/specs.ts +33 -10
  50. package/spec-cli/src/uninstall.ts +0 -9
  51. package/spec-cli/src/uploads.ts +240 -18
  52. package/spec-cli/src/worktree-sources.ts +0 -16
  53. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  54. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
  56. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  58. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  59. package/spec-cli/templates/spexcode.json +12 -0
  60. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  61. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  62. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  63. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  64. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  65. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  66. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  67. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  68. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  69. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  70. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  71. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  72. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  73. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  74. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  75. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  76. package/spec-dashboard/dist/index.html +2 -2
  77. package/spec-dashboard/src/session.js +1 -28
  78. package/spec-eval/src/cache.ts +0 -14
  79. package/spec-eval/src/cli.ts +26 -5
  80. package/spec-eval/src/evaltab.ts +67 -108
  81. package/spec-eval/src/filing.ts +0 -18
  82. package/spec-eval/src/freshness.ts +78 -32
  83. package/spec-eval/src/humanok.ts +0 -15
  84. package/spec-eval/src/scenariofresh.ts +8 -80
  85. package/spec-eval/src/scenarios.ts +16 -67
  86. package/spec-eval/src/sessioneval.ts +157 -78
  87. package/spec-eval/src/sidecar.ts +1 -70
  88. package/spec-eval/src/timeline.ts +0 -23
  89. package/spec-forge/src/cache.ts +34 -12
  90. package/spec-forge/src/cli.ts +0 -6
  91. package/spec-forge/src/drivers/github.ts +1 -12
  92. package/spec-forge/src/drivers/gitlab.ts +4 -19
  93. package/spec-forge/src/drivers.ts +0 -18
  94. package/spec-forge/src/links.ts +0 -4
  95. package/spec-forge/src/needs-eval.ts +0 -1
  96. package/spec-forge/src/port.ts +0 -12
  97. package/spec-forge/src/resident.ts +4 -20
  98. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  99. package/spec-cli/src/session-maintenance.ts +0 -751
  100. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  101. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  102. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  103. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  104. /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
+ }
@@ -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, runIssueWrite, ISSUE_WRITE_SUBS } from './localIssues.js'
14
- import { dispatchMentions, parseMentions, type DispatchOutcome, type LoopIn } from './mentions.js'
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 => EVAL_CONCERN_RE.test(concern)
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 m = EVAL_CONCERN_RE.exec(t.concern)
98
- if (!m) continue
99
- const node = m[1].trim(), scenario = m[2].trim()
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; outcomes: DispatchOutcome[] }> {
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, outcomes } = await postLocalIssue(concern, {
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, outcomes }
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
- const id = `${driver.host}#${number}`
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; outcomes: DispatchOutcome[]; loopIn: LoopIn | null }> {
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
- // replyLocalIssue also loops in the thread's originator ([[mentions]]) after the @-dispatch.
260
- const { thread, outcomes, loopIn } = await replyLocalIssue(id, body, author, opts.evidence)
261
- return { store: 'local', replies: thread.replies, outcomes, loopIn }
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
- const outcomes = await dispatchMentions(body, { threadId: id, node: opts.node ?? null, author })
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
- }