spexcode 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +149 -102
  2. package/README.zh-CN.md +170 -0
  3. package/package.json +1 -1
  4. package/spec-cli/bin/spex.mjs +24 -1
  5. package/spec-cli/src/attach.ts +50 -0
  6. package/spec-cli/src/cli.ts +227 -66
  7. package/spec-cli/src/client.ts +47 -9
  8. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  9. package/spec-cli/src/gateway.ts +15 -11
  10. package/spec-cli/src/guide.ts +73 -17
  11. package/spec-cli/src/harness.ts +48 -19
  12. package/spec-cli/src/help.ts +141 -51
  13. package/spec-cli/src/index.ts +41 -14
  14. package/spec-cli/src/issues.ts +109 -31
  15. package/spec-cli/src/layout.ts +4 -4
  16. package/spec-cli/src/localIssues.ts +59 -58
  17. package/spec-cli/src/materialize.ts +4 -2
  18. package/spec-cli/src/mentions.ts +22 -1
  19. package/spec-cli/src/pty-bridge.ts +39 -4
  20. package/spec-cli/src/ranker.ts +31 -12
  21. package/spec-cli/src/search.bench.mjs +30 -7
  22. package/spec-cli/src/search.ts +39 -0
  23. package/spec-cli/src/sessions.ts +149 -62
  24. package/spec-cli/src/specs.ts +16 -4
  25. package/spec-cli/src/supervise.ts +30 -6
  26. package/spec-cli/src/tree.ts +118 -0
  27. package/spec-cli/templates/hooks/post-merge +2 -2
  28. package/spec-cli/templates/hooks/pre-commit +34 -15
  29. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  30. package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
  31. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
  40. package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
  41. package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-forge/src/cli.ts +4 -10
  44. package/spec-forge/src/drivers.ts +13 -0
  45. package/spec-yatsu/src/cli.ts +89 -15
  46. package/spec-yatsu/src/scenariofresh.ts +100 -30
  47. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
  48. package/spec-dashboard/dist/assets/index-BTU-44Os.css +0 -32
@@ -9,8 +9,9 @@
9
9
  // (createIssue/createComment/closeIssue — the driver stays the only network toucher; the tracer stays read-only).
10
10
  import type { ForgeIssue, ForgePR } from '../../spec-forge/src/port.js'
11
11
  import { resolveLinks } from '../../spec-forge/src/links.js'
12
- import { loadLocalIssues, loadOne, reply, resolve, issuesEnabled, replyLocalIssue, runIssueWrite, ISSUE_WRITE_SUBS } from './localIssues.js'
13
- import { dispatchMentions, type DispatchOutcome, type LoopIn } from './mentions.js'
12
+ import { DEFAULT_FORGE_HOST, FORGE_DRIVERS, forgeDriverFor, forgeIssueStores } 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'
14
15
  import { envSessionId } from './layout.js'
15
16
  import { loadSpecsLite } from './specs.js'
16
17
 
@@ -35,9 +36,8 @@ export type Issue = {
35
36
  store: string // 'local' | a forge host ('github') — the adapter that holds it
36
37
  concern: string
37
38
  by: string
38
- status: string // its own lifecycle: local open|accepted|rejected|landed; forge open|closed
39
+ status: string // its own lifecycle: local open|landed; forge open|closed
39
40
  nodes: string[]
40
- signers: string[]
41
41
  created: string
42
42
  body: string
43
43
  replies: Reply[]
@@ -47,6 +47,26 @@ export type Issue = {
47
47
 
48
48
  export type ForgeState = { issues: ForgeIssue[]; prs: ForgePR[] }
49
49
  export type ForgeSlice = { host: string; state: ForgeState }
50
+ export type IssueStore = { id: string; label: string; kind: 'local' | 'forge'; writable: true }
51
+
52
+ export function issueStores(): IssueStore[] {
53
+ return [
54
+ { id: 'local', label: 'local', kind: 'local', writable: true },
55
+ ...forgeIssueStores().map((s) => ({ ...s, writable: true as const })),
56
+ ]
57
+ }
58
+
59
+ function inferNodes(concern: string, body: string | undefined, explicit: string[] = []): string[] {
60
+ return [...new Set([...explicit, ...parseMentions(`${concern}\n${body || ''}`).nodes])]
61
+ }
62
+
63
+ function forgeIssueBody(concern: string, body: string | undefined, nodes: string[], evidence: string[] = []): string {
64
+ return [
65
+ (body || `(no detail given — ${concern})`).trim(),
66
+ nodes.length ? `Spec: ${nodes.join(', ')}` : '',
67
+ evidence.length ? `Evidence: ${evidence.join(', ')} (yatsu blob hashes)` : '',
68
+ ].filter(Boolean).join('\n\n')
69
+ }
50
70
 
51
71
  // ── the (node, scenario) ↔ eval-thread join ([[remark-teeth]]) ────────────────────────────────────────
52
72
  // A scenario's remark track lives ONCE in trunk, keyed by its `eval: <node> · <scenario>` concern thread
@@ -101,7 +121,6 @@ export function fromForge(slice: ForgeSlice, nodeIds: string[]): Issue[] {
101
121
  by: i.author,
102
122
  status: (i.state || '').toLowerCase(),
103
123
  nodes: nodesByNumber.get(i.number) ?? [],
104
- signers: [],
105
124
  created: i.createdAt,
106
125
  body: i.body,
107
126
  // the forge comments ARE the thread — the same Reply shape a local thread carries, so nothing
@@ -127,27 +146,63 @@ export function mergedIssues(forge: ForgeSlice | null, nodeIds: string[]): Issue
127
146
  .sort((a, b) => b.created.localeCompare(a.created))
128
147
  }
129
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.
154
+ export async function createIssue(
155
+ concern: string,
156
+ opts: { store?: string; nodes?: string[]; body?: string; evidence?: string[]; author?: string } = {},
157
+ ): Promise<{ store: string; id: string; nodes: string[]; url?: string; outcomes: DispatchOutcome[] }> {
158
+ const store = opts.store || 'local'
159
+ const author = opts.author || envSessionId() || 'unknown'
160
+ if (store === 'local') {
161
+ const { thread, outcomes } = await postLocalIssue(concern, {
162
+ nodes: opts.nodes,
163
+ body: opts.body,
164
+ evidence: opts.evidence,
165
+ author,
166
+ })
167
+ return { store: 'local', id: thread.id, nodes: thread.nodes, outcomes }
168
+ }
169
+
170
+ const driver = forgeDriverFor(store)
171
+ if (!driver) throw new Error(`unknown issue store '${store}' (known: ${issueStores().map((s) => s.id).join(', ')})`)
172
+ const nodes = inferNodes(concern, opts.body, opts.nodes)
173
+ const { number, url } = await driver.createIssue({
174
+ title: concern,
175
+ body: forgeIssueBody(concern, opts.body, nodes, opts.evidence),
176
+ })
177
+ const id = `${driver.host}#${number}`
178
+ const outcomes = await dispatchMentions(opts.body || concern, { threadId: id, node: nodes[0] || null, author, status: 'open' })
179
+ return { store: driver.host, id, nodes, url, outcomes }
180
+ }
181
+
130
182
  // @@@ promote - the ONE cross-store verb ([[issues]]): a local concern that outgrew the repo moves to the
131
183
  // forge as one recorded action. The forge issue is composed from the thread itself — concern → title;
132
184
  // body + the `Spec: <nodes>` marker (the round-trip: the existing tracer read links it straight back to
133
185
  // the same nodes, no new linking code) + the evidence hashes + a provenance footer — and created through
134
186
  // the driver (the only network toucher). ORDER makes failure safe: create the forge issue FIRST; only
135
- // then close the local thread out (a reply carrying the permalink, then resolve `landed`) — an
187
+ // then close the local thread out (a reply carrying the permalink, then status `landed`) — an
136
188
  // unreachable forge throws with the local thread untouched, and only an `open` thread promotes.
137
- export async function promote(id: string): Promise<{ url: string; number: number; host: string }> {
189
+ // `author` mirrors the other write verbs: the effective session id by default, `'human'` from the dashboard.
190
+ export async function promote(id: string, opts: { author?: string } = {}): Promise<{ url: string; number: number; host: string }> {
191
+ const author = opts.author || envSessionId() || 'unknown'
138
192
  const t = loadOne(id)
139
193
  if (t.status !== 'open') throw new Error(`'${id}' is ${t.status} — only an open local issue promotes`)
140
- const { githubDriver } = await import('../../spec-forge/src/drivers/github.js')
194
+ const driver = forgeDriverFor(DEFAULT_FORGE_HOST)
195
+ if (!driver) throw new Error(`unknown default forge host '${DEFAULT_FORGE_HOST}'`)
141
196
  const body = [
142
197
  t.body,
143
198
  t.nodes.length ? `\nSpec: ${t.nodes.join(', ')}` : '',
144
199
  t.evidence.length ? `\nEvidence: ${t.evidence.join(', ')} (yatsu blob hashes)` : '',
145
- `\n---\nPromoted from the local issue \`${id}\` (opened by ${t.by} @ ${t.created}; promoted by ${envSessionId() || 'unknown'}).`,
200
+ `\n---\nPromoted from the local issue \`${id}\` (opened by ${t.by} @ ${t.created}; promoted by ${author}).`,
146
201
  ].filter(Boolean).join('\n')
147
- const { number, url } = await githubDriver.createIssue({ title: t.concern, body })
148
- reply(id, `promoted to the forge: ${url}`)
149
- resolve(id, 'landed')
150
- return { url, number, host: githubDriver.host }
202
+ const { number, url } = await driver.createIssue({ title: t.concern, body })
203
+ reply(id, `promoted to the forge: ${url}`, author)
204
+ closeLocalIssue(id)
205
+ return { url, number, host: driver.host }
151
206
  }
152
207
 
153
208
  // @@@ replyIssue - ONE reply verb, store-routed ([[issues]]): store is a property of the issue, so
@@ -171,23 +226,23 @@ export async function replyIssue(
171
226
  const { thread, outcomes, loopIn } = await replyLocalIssue(id, body, author, opts.evidence)
172
227
  return { store: 'local', replies: thread.replies, outcomes, loopIn }
173
228
  }
174
- const { githubDriver } = await import('../../spec-forge/src/drivers/github.js')
175
- if (forge[1] !== githubDriver.host) throw new Error(`unknown forge host '${forge[1]}' — this repo's driver is '${githubDriver.host}'`)
176
- const { url } = await githubDriver.createComment({ number: parseInt(forge[2], 10), body })
229
+ const driver = forgeDriverFor(forge[1])
230
+ if (!driver) throw new Error(`unknown forge host '${forge[1]}' — known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')}`)
231
+ const { url } = await driver.createComment({ number: parseInt(forge[2], 10), body })
177
232
  const outcomes = await dispatchMentions(body, { threadId: id, node: opts.node ?? null, author })
178
233
  // a forge issue's author is a github login, not a live session → no reachable originator to loop in (silent).
179
234
  return { store: forge[1], url, outcomes, loopIn: null }
180
235
  }
181
236
 
182
237
  // @@@ closeIssue - ONE lifecycle close over every store ([[issues]]): the issue owns its status, so the
183
- // dashboard Close button routes by id and never writes node state. Local closes resolve the local thread
238
+ // dashboard Close button routes by id and never writes node state. Local closes mark the local thread
184
239
  // `landed`; forge closes call the driver's close verb and let the forced read-back reveal the closed state.
185
240
  export async function closeIssue(id: string): Promise<{ store: string; status: string; url?: string }> {
186
241
  const forge = /^([A-Za-z0-9-]+)#(\d+)$/.exec(id)
187
- if (!forge) return { store: 'local', status: resolve(id, 'landed') }
188
- const { githubDriver } = await import('../../spec-forge/src/drivers/github.js')
189
- if (forge[1] !== githubDriver.host) throw new Error(`unknown forge host '${forge[1]}' — this repo's driver is '${githubDriver.host}'`)
190
- const { url } = await githubDriver.closeIssue({ number: parseInt(forge[2], 10) })
242
+ if (!forge) return { store: 'local', status: closeLocalIssue(id).status }
243
+ const driver = forgeDriverFor(forge[1])
244
+ if (!driver) throw new Error(`unknown forge host '${forge[1]}' — known: ${FORGE_DRIVERS.map((d) => d.host).join(', ')}`)
245
+ const { url } = await driver.closeIssue({ number: parseInt(forge[2], 10) })
191
246
  return { store: forge[1], status: 'closed', url }
192
247
  }
193
248
 
@@ -200,31 +255,55 @@ const hasFlag = (args: string[], name: string) => args.includes(`--${name}`)
200
255
 
201
256
  // `spex issues …` — the ONE issues surface. Bare (with filters) it is THE read over every store: the
202
257
  // drain view a supervisor/human works from, `[--node id] [--store local|<host>] [--all] [--json]`. A write
203
- // first-positional (open|reply|sign|resolve|on|off|status|nudge — localIssues.ts) routes to the store's
204
- // write verbs; `promote` is the one cross-store verb. The list imposes NO salience ranking — recurrence
205
- // (signers, replies) is a signal the drain WEIGHS by judgment, never an automatic priority order. The forge
206
- // slice is a LIVE pull; an unreachable forge degrades loudly to local-only (one stderr note) local
207
- // reading never hostages on a network.
258
+ // first-positional (open|reply|on|off|status|nudge — localIssues.ts) routes to the store's
259
+ // write verbs (open and reply are themselves store-routed: `open --store <host>` / a `<host>#<n>` id go
260
+ // through the driver); `close` is the store-routed lifecycle verb (the SAME closeIssue the dashboard's
261
+ // Close button calls); `promote` is the one cross-store verb. The list imposes NO salience ranking
262
+ // replies are a signal the drain WEIGHS by judgment, never an automatic priority
263
+ // order. The forge slice is a LIVE pull; an unreachable forge degrades loudly to local-only (one stderr
264
+ // note) — local reading never hostages on a network.
208
265
  export async function runIssues(args: string[]): Promise<number> {
209
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
+ }
210
284
  if (args[0] === 'promote') {
211
285
  const id = args[1]
212
286
  if (!id || id.startsWith('--')) { console.error('usage: spex issues promote <local-issue-id>'); return 2 }
213
287
  try {
214
288
  const r = await promote(id)
215
- console.log(`promoted '${id}' → ${r.host}#${r.number} ${r.url}\n local thread resolved landed (permalink recorded in its reply trail)`)
289
+ console.log(`promoted '${id}' → ${r.host}#${r.number} ${r.url}\n local thread closed landed (permalink recorded in its reply trail)`)
216
290
  return 0
217
291
  } catch (e) {
218
292
  console.error(`spex issues promote: ${e instanceof Error ? e.message : e}`)
219
293
  return 1
220
294
  }
221
295
  }
296
+ if (args[0] && !args[0].startsWith('--')) {
297
+ console.error(`spex issues: unknown subcommand '${args[0]}'`)
298
+ return 2
299
+ }
222
300
  const nodeIds = loadSpecsLite().map((s) => s.id)
223
301
  let forge: ForgeSlice | null = null
224
302
  try {
225
- const { githubDriver } = await import('../../spec-forge/src/drivers/github.js')
226
- const [issues, prs] = await Promise.all([githubDriver.listIssues(), githubDriver.listPRs()])
227
- forge = { host: githubDriver.host, state: { issues, prs } }
303
+ const driver = forgeDriverFor(DEFAULT_FORGE_HOST)
304
+ if (!driver) throw new Error(`unknown default forge host '${DEFAULT_FORGE_HOST}'`)
305
+ const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()])
306
+ forge = { host: driver.host, state: { issues, prs } }
228
307
  } catch (e) {
229
308
  console.error(`spex issues: forge unreachable — listing local only (${e instanceof Error ? e.message.split('\n')[0] : e})`)
230
309
  }
@@ -241,7 +320,6 @@ export async function runIssues(args: string[]): Promise<number> {
241
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(' · ')
242
321
  console.log(`• ${p.concern} [${p.id}]`)
243
322
  console.log(` ${tags}`)
244
- if (p.signers.length) console.log(` +${p.signers.length} signed: ${p.signers.join(', ')}`)
245
323
  if (p.replies.length) console.log(` ${p.replies.length} reply(ies) in thread`)
246
324
  if (p.url) console.log(` ${p.url}`)
247
325
  }
@@ -27,14 +27,14 @@ type Config = {
27
27
  }
28
28
  sessions?: {
29
29
  maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 8; see sessions.ts maxActive)
30
- claudeCmd?: string // the UNNAMED default worker launcher for Claude (default 'claude --dangerously-skip-permissions'); env SPEXCODE_CLAUDE_CMD overrides. A host-specific ABS path belongs in the gitignored spexcode.local.json, not here.
31
- codexCmd?: string // the UNNAMED default worker launcher for Codex (default 'codex --yolo'); env SPEXCODE_CODEX_CMD overrides. Same host-path rule.
30
+ claudeCmd?: string // the built-in `claude` launcher command (default 'claude --dangerously-skip-permissions'); env SPEXCODE_CLAUDE_CMD overrides. A host-specific ABS path belongs in the gitignored spexcode.local.json, not here.
31
+ codexCmd?: string // the built-in `codex` launcher command (default 'codex --yolo'); env SPEXCODE_CODEX_CMD overrides. Same host-path rule.
32
32
  // named launcher profiles: a session picks ONE by name at create time ([[launcher-select]]), fixing both
33
33
  // its harness AND its exact launch command; the chosen NAME is persisted on the record so resume reuses the
34
34
  // same auth. `harness` defaults to 'claude'. Host-specific `cmd`s (abs wrapper paths) belong in the
35
35
  // gitignored spexcode.local.json — the name is portable, the cmd is a machine fact.
36
36
  launchers?: { [name: string]: { harness?: 'claude' | 'codex'; cmd: string } }
37
- defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses (else the unnamed claudeCmd/codexCmd path)
37
+ defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses; required for no-choice creates
38
38
  }
39
39
  serve?: {
40
40
  // public-exposure config for `spex serve --public` (resolved gateway-side; see [[public-mode]] / gateway.ts).
@@ -161,7 +161,7 @@ export type RawRecord = {
161
161
  node: string | null; title: string | null; name: string | null; parent?: string | null
162
162
  status: string; proposal: string | null; merges: number; note: string | null
163
163
  sortkey: number | null; createdAt: number; harness?: string; harness_session_id?: string
164
- launcher?: string // the named launcher profile this session was created under ([[launcher-select]]); absent/empty the unnamed global default
164
+ launcher?: string // the launcher profile this session was created under ([[launcher-select]]); absent/empty only on old records predating launchers
165
165
  launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
166
166
  }
167
167
  // the agent's OWN session id from the environment — the only locator now that the record left the worktree.
@@ -2,7 +2,7 @@
2
2
  // [[mentions]]). A thread IS an Issue whose `store` is 'local' — membership implied by WHERE the file lives,
3
3
  // never written into it. One thread = one PLAIN markdown file at <main>/.spec/.issues/<id>.md; there is
4
4
  // deliberately no content-kind taxonomy (a change suggestion, an annotation, a Q&A are the same mechanism —
5
- // the prose says what it is). Others sign/reply/discuss like an async chatroom; a supervisor drains it via
5
+ // the prose says what it is). Others reply/discuss like an async chatroom; a supervisor drains it via
6
6
  // `spex issues` (reading is the port's job, issues.ts — this module owns only the store + its write verbs).
7
7
  // Because a thread file is NOT named spec.md, the spec walk never nodes it and isSpecMd ignores it —
8
8
  // invisible to lint / drift / deriveStatus / board with ZERO exemption. The store lives on the TRUNK, not
@@ -87,10 +87,9 @@ const currentSession = (): string => envSessionId() || 'unknown'
87
87
  const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
88
88
 
89
89
  // ── file format ──────────────────────────────────────────────────────────────────────────────────────
90
- // frontmatter (concern/by/status/nodes/evidence/signers/created) + a prose body, then any replies — each
90
+ // frontmatter (concern/by/status/nodes/evidence/created) + a prose body, then any replies — each
91
91
  // preceded by a `<!-- reply: <by> @ <iso> -->` sentinel: invisible in rendered markdown, unambiguous to
92
- // parse. Only the store writes these files, so a fixed shape is safe. Lists are `key: a, b` scalars so a
93
- // +1 sign is a one-line change.
92
+ // parse. Only the store writes these files, so a fixed shape is safe.
94
93
  //
95
94
  // A REMARK ([[remark-substrate]]) is a reply carrying extra state, appended to the SAME sentinel as a
96
95
  // ` :: <space-joined k=v attrs>` tail (a plain reply has no tail → parses unchanged, backward compatible):
@@ -120,9 +119,8 @@ function parse(id: string, text: string): Issue {
120
119
  store: 'local',
121
120
  concern: fm.concern || id,
122
121
  by: fm.by || 'unknown',
123
- status: fm.status || 'open',
122
+ status: fm.status === 'landed' ? 'landed' : 'open',
124
123
  nodes: list(fm.nodes),
125
- signers: list(fm.signers),
126
124
  created: fm.created || '',
127
125
  body: body.join('\n').trim(),
128
126
  replies: replies.map((r) => ({ ...r, body: r.body.trim() })),
@@ -165,7 +163,6 @@ function serialize(p: Issue): string {
165
163
  `status: ${safeScalar(p.status)}`,
166
164
  p.nodes.length ? `nodes: ${p.nodes.join(', ')}` : '',
167
165
  p.evidence.length ? `evidence: ${p.evidence.join(', ')}` : '',
168
- p.signers.length ? `signers: ${p.signers.join(', ')}` : '',
169
166
  `created: ${p.created}`,
170
167
  ].filter(Boolean)
171
168
  let out = `---\n${fm.join('\n')}\n---\n\n${safeBody(p.body)}\n`
@@ -259,7 +256,7 @@ function ensureStoreMigrated(): void {
259
256
  // structurally invisible to spec-lint, and the commit is provably store-only (one .spec/.issues/ path), so the
260
257
  // pre-commit gate would only pass anyway — running it just burns seconds (tsx cold-start) holding the lock.
261
258
  // A NO-CHANGE write is idempotent SUCCESS, never a failure: when the serialized bytes already equal the
262
- // stored state (a duplicate resolve/sign — the store IS the requested state), `git commit` would exit 1
259
+ // stored state (a duplicate close — the store IS the requested state), `git commit` would exit 1
263
260
  // 'nothing to commit', so we detect the no-op after `add` (the staged path equals HEAD → status is silent)
264
261
  // and skip the commit. Returns whether the store actually changed, so a caller can say 'already <state>'.
265
262
  // MUST run while holding withStoreLock — it is the write half of a locked read-modify-write; its callers
@@ -286,7 +283,7 @@ function writeStoreFile(p: Issue, message: string): boolean {
286
283
  }
287
284
 
288
285
  // prepare (a FRESH read-modify or a new thread) + write + commit a single store file, all under the store
289
- // lock so the read-modify-write is atomic. prepare() runs INSIDE the lock, so a reply/sign/resolve reads the
286
+ // lock so the read-modify-write is atomic. prepare() runs INSIDE the lock, so a reply/close reads the
290
287
  // current thread, never a stale copy. A pre-rename store migrates first (before the lock — ensure takes it).
291
288
  // `changed: false` = the store already held exactly this state (see writeStoreFile) — success, nothing committed.
292
289
  function commitStore(message: string, prepare: () => Issue): { issue: Issue; changed: boolean } {
@@ -312,7 +309,6 @@ export function openIssue(concern: string, opts: { nodes?: string[]; body?: stri
312
309
  by: opts.author || currentSession(),
313
310
  status: 'open',
314
311
  nodes,
315
- signers: [],
316
312
  created: new Date().toISOString(),
317
313
  body: (opts.body || `(no detail given — ${concern})`).trim(),
318
314
  replies: [],
@@ -395,25 +391,13 @@ export async function postLocalIssue(
395
391
  return { thread, outcomes }
396
392
  }
397
393
 
398
- export function sign(id: string): string[] {
399
- const by = currentSession()
400
- return commitStore(`issue(${id}): signed by ${by}`, () => {
394
+ export function closeLocalIssue(id: string): { status: 'landed'; already: boolean } {
395
+ const { changed } = commitStore(`issue(${id}): close`, () => {
401
396
  const p = loadOne(id)
402
- if (!p.signers.includes(by)) p.signers.push(by)
403
- return p
404
- }).issue.signers
405
- }
406
-
407
- const RESOLUTIONS = new Set(['accepted', 'rejected', 'landed'])
408
- // `already` = the thread was in that state before this call — an idempotent success, nothing committed.
409
- export function resolve(id: string, as: string): { as: string; already: boolean } {
410
- if (!RESOLUTIONS.has(as)) throw new Error(`resolution must be one of: ${[...RESOLUTIONS].join(' | ')}`)
411
- const { changed } = commitStore(`issue(${id}): resolve ${as}`, () => {
412
- const p = loadOne(id)
413
- p.status = as
397
+ p.status = 'landed'
414
398
  return p
415
399
  })
416
- return { as, already: !changed }
400
+ return { status: 'landed', already: !changed }
417
401
  }
418
402
 
419
403
  // ── remarks ([[remark-substrate]]) ──────────────────────────────────────────────────────────────────
@@ -439,7 +423,7 @@ function findOrCreateEvalThread(node: string, scenario: string, author: string):
439
423
  if (existing) return existing
440
424
  const p: Issue = {
441
425
  id: uniqueId(concern), store: 'local', concern, by: author, status: 'open',
442
- nodes: [node], signers: [], created: new Date().toISOString(),
426
+ nodes: [node], created: new Date().toISOString(),
443
427
  body: `Remarks on the \`${scenario}\` eval of [[${node}]].`, replies: [], evidence: [],
444
428
  }
445
429
  writeStoreFile(p, `issue: ${concern}`)
@@ -479,16 +463,18 @@ function parseRemarkRef(ref: string): { id: string; rid: string } {
479
463
  return { id: ref.slice(0, i), rid: ref.slice(i + 1) }
480
464
  }
481
465
 
482
- // resolve a remark (R3): a DELIBERATE call, agent-only (the dashboard's `human` is rejected a human
483
- // RETRACTS their own, resolve is a second party's judgment), NEVER the author (no self-resolve), and
484
- // MONOTONIC (no un-resolve — a regression is a NEW remark). `by` is the resolving party.
466
+ // resolve a remark (R3): a DELIBERATE call by a SECOND PARTY never the author (no self-resolve: an
467
+ // identity comparison, so the dashboard's `human` cannot resolve a human-authored remark either), and
468
+ // MONOTONIC (no un-resolve — a regression is a NEW remark). The resolver's identity is derived by the
469
+ // SURFACE ([[remark-substrate]] LAW L): a governed session id from the CLI, the `human` sentinel from the
470
+ // dashboard — both are real second parties, so the same rule runs on both. `by` is the resolving party.
485
471
  export function resolveRemark(ref: string, by: string): { thread: Issue; rid: string } {
486
472
  const { id, rid } = parseRemarkRef(ref)
487
473
  const { issue: thread } = commitStore(`remark(${id}#${rid}): resolved by ${by}`, () => {
488
474
  const p = loadOne(id)
489
475
  const r = p.replies.find((x) => x.rid === rid)
490
476
  if (!r) throw new Error(`no remark '${ref}' in that thread`)
491
- if (!by || by === 'human' || by === 'unknown') throw new Error(`resolve is agent-only (needs a real session identity, got '${by || 'none'}'): a human withdraws their own remark with \`spex retract\`, not resolve`)
477
+ if (!by || by === 'unknown') throw new Error(`resolve needs a real identity (got '${by || 'none'}'): run it under a governed session, or from the dashboard (its actor is 'human')`)
492
478
  if (r.resolved) throw new Error(`remark '${ref}' is already resolved — monotonic: a regression is a NEW remark, never an un-resolve`)
493
479
  if (r.by === by) throw new Error(`refusing to self-resolve '${ref}': the author (${by}) may not resolve their own remark — resolve is a second party's deliberate judgment`)
494
480
  r.resolved = true; r.resolvedBy = by; r.resolvedAt = new Date().toISOString()
@@ -525,28 +511,45 @@ export function nudge(node: string): string {
525
511
  '── issues ─────────────────────────────────────────────────────────',
526
512
  `Your work (${node || 'this node'}) just landed. Two issue checks before you close:`,
527
513
  '',
528
- '1. CLOSE what you finished. An issue whose work just landed is resolved, not',
514
+ '1. CLOSE what you finished. An issue whose work just landed is closed, not',
529
515
  ' left open — the open set is the OUTSTANDING work, so a stale open reads as a',
530
- ' lie: spex issues --store local then spex issues resolve <id> --as landed',
516
+ ' lie: spex issues --store local then spex issues close <id>',
531
517
  '',
532
518
  '2. RECORD only what OUTLIVES this task — a concern you are NOT acting on now:',
533
519
  ' an off-mainline smell / awkward boundary / wish, or a trivial-but-must-not-',
534
520
  " forget to-do that doesn't earn a spec node. NOT a bug tracker (that is the",
535
521
  ' spec graph + the forge), NOT your assigned task or a fix you are about to',
536
522
  ' make — those need no issue. Only the taste that would otherwise evaporate:',
537
- ' spex issues # read first — sign/reply if already raised',
523
+ ' spex issues # read first — reply if already raised',
538
524
  ' spex issues open "<concern>" [--node <id>] # else open one',
539
525
  'A supervisor drains the store later. (Advisory — skip if nothing is owed.)',
540
526
  '───────────────────────────────────────────────────────────────────',
541
527
  ].join('\n')
542
528
  }
543
529
 
530
+ // @@@ close-time issue closeout ([[local-issues]] / [[state]]) — the DATA half of the propose-close nudge:
531
+ // appended to the `done --propose close` declaration BESIDE the resource-cleanup reminder (cli.ts, same
532
+ // insertion point, same semantics — a nudge, never a gate). Data-driven so it earns its line: it lists the
533
+ // still-open local threads THIS session touched (authored or replied; eval `eval: <node> · <scenario>`
534
+ // containers excluded — they host remarks and outlive every session by design) and prints NOTHING when the
535
+ // session owes nothing, when the feature is OFF, or when there is no session identity. Some issues rightly
536
+ // outlive their session (a taste concern waiting for the drain), so the ask is close OR say why it stays
537
+ // open — never a forced close.
538
+ export function closeoutNudge(sessionId: string | null | undefined): string {
539
+ if (!sessionId || sessionId === 'unknown' || !issuesEnabled()) return ''
540
+ const mine = loadLocalIssues().filter((t) =>
541
+ t.status === 'open' && !EVAL_CONCERN_RE.test(t.concern) &&
542
+ (t.by === sessionId || t.replies.some((r) => r.by === sessionId)))
543
+ if (!mine.length) return ''
544
+ return `\n\nIssue closeout — ${mine.length} still-open local issue(s) you touched (opened or replied): ${mine.map((t) => t.id).join(', ')}. For each, close it now if its work is finished (\`spex issues close <id>\`), or reply why it should stay open past this session (\`spex issues reply <id> --body "<why>"\`). Some issues rightly outlive their session — this is a reminder to sweep, not a gate.`
545
+ }
546
+
544
547
  // ───────────────────────── CLI ─────────────────────────
545
548
  const fl = (args: string[], name: string): string | undefined => {
546
549
  const i = args.indexOf(`--${name}`)
547
550
  return i >= 0 ? args[i + 1] : undefined
548
551
  }
549
- const VALUE_FLAGS = new Set(['--node', '--body', '--as', '--evidence', '--scenario', '--code-sha'])
552
+ const VALUE_FLAGS = new Set(['--node', '--body', '--evidence', '--scenario', '--code-sha', '--store'])
550
553
  // bare positionals, skipping flags + their values.
551
554
  function bare(args: string[]): string[] {
552
555
  const out: string[] = []
@@ -568,9 +571,10 @@ const repeated = (args: string[], name: string): string[] =>
568
571
  args.flatMap((a, i) => (a === `--${name}` ? [args[i + 1]] : [])).filter(Boolean) as string[]
569
572
 
570
573
  // the local-issue WRITE verbs, folded into the one issues surface (`spex issues <sub>` — the read routes
571
- // here when its first positional is a write sub): open "<concern>" [--node id…] [--evidence hash…]
572
- // [--body -|text], the id-based reply | sign | resolve, and the feature toggle on | off | status. `nudge`
573
- // is internal (the post-merge hook's caller). Store is a property of the issue, never a second command.
574
+ // here when its first positional is a write sub): open "<concern>" [--store local|<host>] [--node id…]
575
+ // [--evidence hash…] [--body -|text], the id-based reply, and the feature toggle
576
+ // on | off | status. `nudge` is internal (the post-merge hook's caller). Store is a property of the issue,
577
+ // never a second command — open and reply route by it (issues.ts createIssue/replyIssue).
574
578
  export async function runIssueWrite(args: string[]): Promise<number> {
575
579
  const sub = args[0]
576
580
  try {
@@ -594,35 +598,32 @@ export async function runIssueWrite(args: string[]): Promise<number> {
594
598
  if (s) console.log(` ${s}`)
595
599
  return 0
596
600
  }
597
- if (sub === 'sign') {
598
- const id = bare(args.slice(1))[0]
599
- if (!id) { console.error('usage: spex issues sign <issue-id>'); return 2 }
600
- console.log(`signed '${id}' — signers: ${sign(id).join(', ')}`)
601
- return 0
602
- }
603
- if (sub === 'resolve') {
604
- const id = bare(args.slice(1))[0]
605
- const as = fl(args, 'as')
606
- if (!id || !as) { console.error('usage: spex issues resolve <issue-id> --as accepted|rejected|landed'); return 2 }
607
- const r = resolve(id, as)
608
- console.log(r.already ? `'${id}' already ${r.as} — store unchanged` : `resolved '${id}' → ${r.as}`)
609
- return 0
610
- }
611
601
  if (sub === 'nudge') {
612
602
  // internal: the post-merge hook calls this to print the (toggle-aware) nudge for a merged node.
613
603
  const text = nudge(bare(args.slice(1))[0] || '')
614
604
  if (text) console.log(text)
615
605
  return 0
616
606
  }
617
- // `open`: start a new local issue. The concern is the bare positional(s) after the sub.
607
+ // `open`: start a new issue STORE-ROUTED through the one creation port ([[issues]] createIssue, the
608
+ // same routine POST /api/issues runs): default local commits to the trunk store; `--store <host>`
609
+ // creates the real forge issue through that store's driver (no promote round-trip when the concern is
610
+ // born forge-visible). The concern is the bare positional(s) after the sub.
618
611
  const concern = sub === 'open' ? bare(args.slice(1)).join(' ').trim() : ''
619
612
  if (!concern) {
620
- console.error('usage: spex issues open "<concern>" [--node <id>…] [--evidence <hash>…] [--body -|<text>]\n spex issues reply|sign|resolve <issue-id> … | on|off|status')
613
+ console.error('usage: spex issues open "<concern>" [--store local|<host>] [--node <id>…] [--evidence <hash>…] [--body -|<text>]\n spex issues reply|close|promote <issue-id> … | on|off|status')
621
614
  return 2
622
615
  }
623
- const p = openIssue(concern, { nodes: repeated(args, 'node'), body: readBody(args), evidence: repeated(args, 'evidence') })
624
- console.log(`opened '${p.id}'${p.nodes.length ? ` (re: ${p.nodes.join(', ')})` : ''} — committed to the local issue store; read it with \`spex issues\``)
625
- const s = summarize(await dispatchMentions(p.body || concern, { threadId: p.id, node: p.nodes[0] || null, author: p.by, status: p.status }))
616
+ const r = await (await import('./issues.js')).createIssue(concern, {
617
+ store: fl(args, 'store'),
618
+ nodes: repeated(args, 'node'),
619
+ body: readBody(args),
620
+ evidence: repeated(args, 'evidence'),
621
+ })
622
+ const re = r.nodes.length ? ` (re: ${r.nodes.join(', ')})` : ''
623
+ console.log(r.store === 'local'
624
+ ? `opened '${r.id}'${re} — committed to the local issue store; read it with \`spex issues\``
625
+ : `opened '${r.id}' on ${r.store}${re} — ${r.url}`)
626
+ const s = summarize(r.outcomes)
626
627
  if (s) console.log(` ${s}`)
627
628
  return 0
628
629
  } catch (e) {
@@ -633,7 +634,7 @@ export async function runIssueWrite(args: string[]): Promise<number> {
633
634
 
634
635
  // the first positionals runIssueWrite handles — the issues command routes these to it, everything else is
635
636
  // the read. Exported so the router and the runner can never drift.
636
- export const ISSUE_WRITE_SUBS = new Set(['open', 'reply', 'sign', 'resolve', 'on', 'off', 'status', 'nudge'])
637
+ export const ISSUE_WRITE_SUBS = new Set(['open', 'reply', 'on', 'off', 'status', 'nudge'])
637
638
 
638
639
  // ── remark CLI ([[remark-substrate]]) — CLI-first: the whole author→resolve→retract loop, no server needed ──
639
640
  // `spex remark <host> --body -|<text> [--code-sha <sha>] [--scenario <name>] [--evidence <hash>…]`
@@ -9,7 +9,6 @@ import { git } from './git.js'
9
9
  import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
10
10
  import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
11
11
  import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
12
- import { tsxBin } from './tsx-bin.js'
13
12
 
14
13
  // @@@ materialize - the "pay-per-change" node step (≈0.85s) the cheap shell gate invokes ONLY when the
15
14
  // .config content-hash moved. It renders the spec tree's surface nodes into the flat artifacts each
@@ -26,7 +25,10 @@ import { tsxBin } from './tsx-bin.js'
26
25
 
27
26
  const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
28
27
  const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
29
- const SPEX = `${tsxBin(PKG)} ${join(PKG, 'src', 'cli.ts')}`
28
+ // the ONE spex entry: the launcher (bin/spex.mjs), never a raw `tsx cli.ts` pair — the launcher owns tsx
29
+ // resolution AND the mid-merge guard (a conflicted source tree degrades to one line + exit 75, not an
30
+ // esbuild stacktrace), so every hook-baked callback inherits both.
31
+ const SPEX = join(PKG, 'bin', 'spex.mjs')
30
32
  // the manifest + content-hash marker render into the GLOBAL per-project store (layout.runtimeRoot), NOT the
31
33
  // worktree — the worktree keeps zero SpexCode-rendered runtime; only the harness-discovered contract files +
32
34
  // shims (which the harness must find in-tree) are written under proj below.
@@ -13,6 +13,18 @@ const NODE_RE = /\[\[([^\]\s]+)\]\]/g
13
13
 
14
14
  const uniq = (xs: string[]): string[] => [...new Set(xs)]
15
15
 
16
+ // ── CLI sigil tolerance ───────────────────────────────────────────────────────────────────────────────
17
+ // In FREE TEXT the sigils are required — they are what marks a reference apart from prose. In a CLI
18
+ // ARGUMENT the whole token IS the reference, so the sigil is optional: `spex review @graph` ≡
19
+ // `spex review graph`, `spex yatsu eval [[cli-surface]]` ≡ `spex yatsu eval cli-surface`. One shared strip,
20
+ // applied by the session-selector matcher and every node-arg read site, so the habit a user learns in the
21
+ // dashboard's input boxes works verbatim on the CLI — never a second grammar to learn.
22
+ export function stripRefSigil(token: string): string {
23
+ const wrapped = /^\[\[(.*)\]\]$/.exec(token)
24
+ if (wrapped) return wrapped[1]
25
+ return token.startsWith('@') ? token.slice(1) : token
26
+ }
27
+
16
28
  export function parseMentions(text: string): { actors: string[]; nodes: string[] } {
17
29
  const actors: string[] = []
18
30
  const nodes: string[] = []
@@ -47,6 +59,15 @@ export function resolveActors(tokens: string[], sessions: ActorSession[]): Resol
47
59
  })
48
60
  }
49
61
 
62
+ // Any spawn's parent = its originator ([[session-nesting]]): the `@new` worker nests under the session that
63
+ // wrote the mention — but ONLY when the author IS a real board session id. A dashboard 'human', a CLI
64
+ // 'unknown', or a forge login resolves to no session → null → a top-level worker, never a phantom nest.
65
+ // Exact id match only (lineage is provenance, not addressing — no prefix/name resolution), any liveness:
66
+ // a parent that later closes is auto-promoted at read time by the derived tree.
67
+ export function spawnParent(author: string, sessions: { id: string }[]): string | null {
68
+ return sessions.some((s) => s.id === author) ? author : null
69
+ }
70
+
50
71
  // ── dispatch (integration) ─────────────────────────────────────────────────────────────────────────────
51
72
  export type DispatchOutcome = { token: string; result: 'sent' | 'spawned' | 'offline' | 'unresolved' | 'failed'; detail?: string; note?: string }
52
73
 
@@ -92,7 +113,7 @@ export async function dispatchMentions(
92
113
  // deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
93
114
  const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
94
115
  try {
95
- const s = await newSession(ctx.node, newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status))
116
+ const s = await newSession(ctx.node, newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status), spawnParent(ctx.author, sessions))
96
117
  out.push({ token: r.token, result: 'spawned', detail: s.id, ...(settled ? { note: `thread ${settled}` } : {}) })
97
118
  } catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
98
119
  continue