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.
- package/README.md +149 -102
- package/README.zh-CN.md +170 -0
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +24 -1
- package/spec-cli/src/attach.ts +50 -0
- package/spec-cli/src/cli.ts +227 -66
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/gateway.ts +15 -11
- package/spec-cli/src/guide.ts +73 -17
- package/spec-cli/src/harness.ts +48 -19
- package/spec-cli/src/help.ts +141 -51
- package/spec-cli/src/index.ts +41 -14
- package/spec-cli/src/issues.ts +109 -31
- package/spec-cli/src/layout.ts +4 -4
- package/spec-cli/src/localIssues.ts +59 -58
- package/spec-cli/src/materialize.ts +4 -2
- package/spec-cli/src/mentions.ts +22 -1
- package/spec-cli/src/pty-bridge.ts +39 -4
- package/spec-cli/src/ranker.ts +31 -12
- package/spec-cli/src/search.bench.mjs +30 -7
- package/spec-cli/src/search.ts +39 -0
- package/spec-cli/src/sessions.ts +149 -62
- package/spec-cli/src/specs.ts +16 -4
- package/spec-cli/src/supervise.ts +30 -6
- package/spec-cli/src/tree.ts +118 -0
- package/spec-cli/templates/hooks/post-merge +2 -2
- package/spec-cli/templates/hooks/pre-commit +34 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
- package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
- package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
- package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
- package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-yatsu/src/cli.ts +89 -15
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +0 -32
package/spec-cli/src/issues.ts
CHANGED
|
@@ -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 {
|
|
13
|
-
import {
|
|
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|
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
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
|
|
148
|
-
reply(id, `promoted to the forge: ${url}
|
|
149
|
-
|
|
150
|
-
return { url, number, 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
|
|
175
|
-
if (
|
|
176
|
-
const { url } = await
|
|
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
|
|
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:
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
190
|
-
const { url } = await
|
|
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|
|
|
204
|
-
// write verbs
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
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
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
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
|
}
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -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
|
|
31
|
-
codexCmd?: string // the
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
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.
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
399
|
-
const
|
|
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
|
-
|
|
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 {
|
|
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],
|
|
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
|
|
483
|
-
//
|
|
484
|
-
// MONOTONIC (no un-resolve — a regression is a NEW remark).
|
|
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 === '
|
|
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
|
|
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
|
|
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 —
|
|
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', '--
|
|
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>" [--
|
|
572
|
-
// [--body -|text], the id-based reply
|
|
573
|
-
// is internal (the post-merge hook's caller). Store is a property of the issue,
|
|
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
|
|
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|
|
|
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
|
|
624
|
-
|
|
625
|
-
|
|
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', '
|
|
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
|
-
|
|
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.
|
package/spec-cli/src/mentions.ts
CHANGED
|
@@ -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
|