spexcode 0.1.5 → 0.2.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 (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +60 -19
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -0,0 +1,250 @@
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 issues`,
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
+ import type { ForgeIssue, ForgePR } from '../../spec-forge/src/port.js'
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'
14
+ import { envSessionId } from './layout.js'
15
+ import { loadSpecsLite } from './specs.js'
16
+
17
+ // A Reply is a plain thread post `{by, at, body}` — OR, when it carries the fields below, a REMARK
18
+ // ([[remark-substrate]]): a reply that pins a RESOLVABLE concern to its host (an issue or a scenario). A
19
+ // remark is not a new record type: it is a reply with the mutable `resolved` bit, a stable `rid` (so it is
20
+ // addressable across retracts), and the `targetCodeSha` it was authored against (the reading it judges). A
21
+ // plain reply omits them all and parses/serializes unchanged (backward compatible). `isRemark` = rid set.
22
+ export type Reply = {
23
+ by: string
24
+ at: string
25
+ body: string
26
+ rid?: string // stable per-remark id; a reply is a remark iff this is set. Ref: `<thread-id>#<rid>`
27
+ targetCodeSha?: string // the reading/codeSha the remark was authored against (worktree HEAD by default)
28
+ resolved?: boolean // the ONE mutable teeth bit — false at author, true after a deliberate `spex resolve`
29
+ resolvedAt?: string
30
+ resolvedBy?: string
31
+ }
32
+ export const isRemark = (r: Reply): boolean => r.rid !== undefined
33
+ export type Issue = {
34
+ id: string
35
+ store: string // 'local' | a forge host ('github') — the adapter that holds it
36
+ concern: string
37
+ by: string
38
+ status: string // its own lifecycle: local open|accepted|rejected|landed; forge open|closed
39
+ nodes: string[]
40
+ signers: string[]
41
+ created: string
42
+ body: string
43
+ replies: Reply[]
44
+ evidence: string[] // yatsu content-addressed blob hashes — the typed cross-node finding reference
45
+ url?: string // a forge permalink; a local issue has none
46
+ }
47
+
48
+ export type ForgeState = { issues: ForgeIssue[]; prs: ForgePR[] }
49
+ export type ForgeSlice = { host: string; state: ForgeState }
50
+
51
+ // ── the (node, scenario) ↔ eval-thread join ([[remark-teeth]]) ────────────────────────────────────────
52
+ // A scenario's remark track lives ONCE in trunk, keyed by its `eval: <node> · <scenario>` concern thread
53
+ // (R4). This is the ONE server-side overlay: the same join the dashboard's Annotator used to compute
54
+ // client-side (concern-key matching), lifted here so the CLI, the board fold, the session proof, and the
55
+ // annotator all read ONE join. It returns, per pair, the thread plus its REMARK replies (the resolvable
56
+ // ones — a plain comment on the thread is not a remark). The teeth ([[remark-teeth]]) read the remark
57
+ // signals; the annotator reads the thread.
58
+ export type RemarkTrack = { threadId: string; node: string; scenario: string; thread: Issue; remarks: Reply[] }
59
+
60
+ // `eval: <node> · <scenario>` — node first (never contains ' · '), then the scenario (may). One thread per
61
+ // pair (EventDetail.jsx evalConcern / localIssues.ts resolveRemarkHost mint it), so the last write wins is fine.
62
+ const EVAL_CONCERN_RE = /^eval: (.+?) · (.+)$/
63
+ export const trackKey = (node: string, scenario: string): string => `${node} · ${scenario}`
64
+
65
+ // an eval-remark thread is the eval scoreboard's data, NOT a drain-worthy issue (I1: a scenario-scoped
66
+ // concern is a remark, never an issue). Its `eval: <node> · <scenario>` concern is the tell — the SAME key
67
+ // loadEvalRemarkTracks isolates them by. The two reads are complementary over one store: mergedIssues (the
68
+ // ISSUE surfaces) excludes these; loadEvalRemarkTracks (the EVAL surfaces) keeps only these.
69
+ export const isEvalConcern = (concern: string): boolean => EVAL_CONCERN_RE.test(concern)
70
+
71
+ // read the whole local store ONCE and split the eval-concern threads out (directive 3): trunk-scoped,
72
+ // read-time, no branch write. A remark whose scenario no longer exists still LOADS here (it just keys a pair
73
+ // no reading joins) — never a crash, per [[remark-teeth]]'s dangling clause.
74
+ export function loadEvalRemarkTracks(): Map<string, RemarkTrack> {
75
+ const out = new Map<string, RemarkTrack>()
76
+ for (const t of loadLocalIssues()) {
77
+ const m = EVAL_CONCERN_RE.exec(t.concern)
78
+ if (!m) continue
79
+ const node = m[1].trim(), scenario = m[2].trim()
80
+ out.set(trackKey(node, scenario), { threadId: t.id, node, scenario, thread: t, remarks: t.replies.filter(isRemark) })
81
+ }
82
+ return out
83
+ }
84
+
85
+ // forge → Issue, at the adapter boundary: the host's node-naming conventions (`Spec:` body marker +
86
+ // transitive PR links — spec-forge links.ts) become plain `nodes[]` HERE, validated against the real node
87
+ // ids, so nothing downstream ever knows a marker existed. Every raw issue maps — linked or not — because
88
+ // the merged list is the whole set, not just the per-node view.
89
+ export function fromForge(slice: ForgeSlice, nodeIds: string[]): Issue[] {
90
+ const nodesByNumber = new Map<number, string[]>()
91
+ for (const link of resolveLinks(slice.state.issues, slice.state.prs, nodeIds))
92
+ for (const i of link.issues) {
93
+ const arr = nodesByNumber.get(i.number) ?? []
94
+ arr.push(link.node)
95
+ nodesByNumber.set(i.number, arr)
96
+ }
97
+ return slice.state.issues.map((i) => ({
98
+ id: `${slice.host}#${i.number}`,
99
+ store: slice.host,
100
+ concern: i.title,
101
+ by: i.author,
102
+ status: (i.state || '').toLowerCase(),
103
+ nodes: nodesByNumber.get(i.number) ?? [],
104
+ signers: [],
105
+ created: i.createdAt,
106
+ body: i.body,
107
+ // the forge comments ARE the thread — the same Reply shape a local thread carries, so nothing
108
+ // downstream renders two kinds of discussion.
109
+ replies: (i.comments ?? []).map((c) => ({ by: c.author, at: c.createdAt, body: c.body })),
110
+ evidence: [],
111
+ url: i.url,
112
+ }))
113
+ }
114
+
115
+ // the one merged read: local issue-store threads + the caller-supplied forge slice, ONE time line — the
116
+ // stores are the same abstraction, so they interleave by creation time, newest first (never
117
+ // store-grouped; a reader's eye lands on what just happened, whatever store holds it). CALLERS own
118
+ // freshness — the server passes the resident cache's state (instant, background reconcile), the CLI a
119
+ // live pull — so the merge itself stays pure. Eval-remark threads are SPLIT OUT read-time (isEvalConcern):
120
+ // they are the eval scoreboard's data, not issues, so every issue surface this feeds — the Threads tab, the
121
+ // board issue badge, the `spex issues` drain — is free of them by construction (they reach the EVAL side
122
+ // through loadEvalRemarkTracks / the reading overlay instead).
123
+ export function mergedIssues(forge: ForgeSlice | null, nodeIds: string[]): Issue[] {
124
+ const remote = forge ? fromForge(forge, nodeIds) : []
125
+ return [...loadLocalIssues(), ...remote]
126
+ .filter((i) => !isEvalConcern(i.concern))
127
+ .sort((a, b) => b.created.localeCompare(a.created))
128
+ }
129
+
130
+ // @@@ promote - the ONE cross-store verb ([[issues]]): a local concern that outgrew the repo moves to the
131
+ // forge as one recorded action. The forge issue is composed from the thread itself — concern → title;
132
+ // body + the `Spec: <nodes>` marker (the round-trip: the existing tracer read links it straight back to
133
+ // the same nodes, no new linking code) + the evidence hashes + a provenance footer — and created through
134
+ // 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
136
+ // 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 }> {
138
+ const t = loadOne(id)
139
+ 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')
141
+ const body = [
142
+ t.body,
143
+ t.nodes.length ? `\nSpec: ${t.nodes.join(', ')}` : '',
144
+ 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'}).`,
146
+ ].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 }
151
+ }
152
+
153
+ // @@@ replyIssue - ONE reply verb, store-routed ([[issues]]): store is a property of the issue, so
154
+ // replying doesn't fork by surface — a local id goes through the store's committed write (localIssues.ts,
155
+ // unchanged), a forge id (`<host>#<n>`) posts a REAL comment through the driver's createComment (the same
156
+ // seam discipline as promotion — no second network call-site). Either way the reply TEXT then dispatches
157
+ // its @-mentions (mentions.ts is store-agnostic: the mention fires on the words, and the mention IS the
158
+ // assign — no separate assign machinery). Callers own freshness: the server refreshes its resident forge
159
+ // slice after a forge write; the CLI's next read is a live pull anyway.
160
+ export async function replyIssue(
161
+ id: string,
162
+ body: string,
163
+ opts: { author?: string; node?: string | null; evidence?: string[] } = {},
164
+ ): Promise<{ store: string; replies?: Reply[]; url?: string; outcomes: DispatchOutcome[]; loopIn: LoopIn | null }> {
165
+ const author = opts.author || envSessionId() || 'unknown'
166
+ const forge = /^([A-Za-z0-9-]+)#(\d+)$/.exec(id)
167
+ if (!forge) {
168
+ // evidence hashes accrue onto the local thread's typed evidence[] (a forge thread has no such field —
169
+ // an annotation's frame rides its comment body's image link there, the driver the only network toucher);
170
+ // replyLocalIssue also loops in the thread's originator ([[mentions]]) after the @-dispatch.
171
+ const { thread, outcomes, loopIn } = await replyLocalIssue(id, body, author, opts.evidence)
172
+ return { store: 'local', replies: thread.replies, outcomes, loopIn }
173
+ }
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 })
177
+ const outcomes = await dispatchMentions(body, { threadId: id, node: opts.node ?? null, author })
178
+ // a forge issue's author is a github login, not a live session → no reachable originator to loop in (silent).
179
+ return { store: forge[1], url, outcomes, loopIn: null }
180
+ }
181
+
182
+ // @@@ 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
184
+ // `landed`; forge closes call the driver's close verb and let the forced read-back reveal the closed state.
185
+ export async function closeIssue(id: string): Promise<{ store: string; status: string; url?: string }> {
186
+ 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) })
191
+ return { store: forge[1], status: 'closed', url }
192
+ }
193
+
194
+ // ───────────────────────── CLI ─────────────────────────
195
+ const fl = (args: string[], name: string): string | undefined => {
196
+ const i = args.indexOf(`--${name}`)
197
+ return i >= 0 ? args[i + 1] : undefined
198
+ }
199
+ const hasFlag = (args: string[], name: string) => args.includes(`--${name}`)
200
+
201
+ // `spex issues …` — the ONE issues surface. Bare (with filters) it is THE read over every store: the
202
+ // 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.
208
+ export async function runIssues(args: string[]): Promise<number> {
209
+ if (ISSUE_WRITE_SUBS.has(args[0])) return runIssueWrite(args)
210
+ if (args[0] === 'promote') {
211
+ const id = args[1]
212
+ if (!id || id.startsWith('--')) { console.error('usage: spex issues promote <local-issue-id>'); return 2 }
213
+ try {
214
+ 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)`)
216
+ return 0
217
+ } catch (e) {
218
+ console.error(`spex issues promote: ${e instanceof Error ? e.message : e}`)
219
+ return 1
220
+ }
221
+ }
222
+ const nodeIds = loadSpecsLite().map((s) => s.id)
223
+ let forge: ForgeSlice | null = null
224
+ 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 } }
228
+ } catch (e) {
229
+ console.error(`spex issues: forge unreachable — listing local only (${e instanceof Error ? e.message.split('\n')[0] : e})`)
230
+ }
231
+ let issues = mergedIssues(forge, nodeIds)
232
+ const node = fl(args, 'node')
233
+ const store = fl(args, 'store')
234
+ if (node) issues = issues.filter((p) => p.nodes.includes(node))
235
+ if (store) issues = issues.filter((p) => p.store === store)
236
+ if (!hasFlag(args, 'all')) issues = issues.filter((p) => p.status === 'open')
237
+ if (hasFlag(args, 'json')) { console.log(JSON.stringify(issues, null, 2)); return 0 }
238
+ if (!issues.length) { console.log(node ? `no issues for node '${node}'` : 'no open issues'); return 0 }
239
+ console.log(`issues — ${issues.length} ${hasFlag(args, 'all') ? 'total' : 'open'}${store ? ` in '${store}'` : ''}${node ? ` for '${node}'` : ''}\n`)
240
+ for (const p of issues) {
241
+ 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
+ console.log(`• ${p.concern} [${p.id}]`)
243
+ console.log(` ${tags}`)
244
+ if (p.signers.length) console.log(` +${p.signers.length} signed: ${p.signers.join(', ')}`)
245
+ if (p.replies.length) console.log(` ${p.replies.length} reply(ies) in thread`)
246
+ if (p.url) console.log(` ${p.url}`)
247
+ }
248
+ if (!issuesEnabled()) console.log('\n(the issues workflow is OFF — `spex issues on` to re-enable writes/nudges)')
249
+ return 0
250
+ }
@@ -9,15 +9,32 @@ type Config = {
9
9
  main?: string // path to the source-of-truth checkout (default: the `main` worktree)
10
10
  mainBranch?: string // source-of-truth BRANCH worktrees fork from (default: auto-detected — see mainBranch())
11
11
  branchPrefix?: string // how a branch names its node (default: "node/")
12
+ preset?: string // the SELECTED init preset — which cumulative .config tier `spex init` seeds (default 'default'; seed-time only, no launcher gate; read by init.ts; see [[init-preset]])
13
+ // PRIVATE-OVERLAY mode ([[private-overlay]]) — belongs in the gitignored spexcode.local.json, NEVER the
14
+ // committed spexcode.json. When true, `spex materialize` leaves ZERO trace in the host's TRACKED files /
15
+ // shared history: the managed ignore entries (incl .spec + spexcode.json, which the DEFAULT mode commits —
16
+ // "git is the database") go to the per-clone `.git/info/exclude`, and any host-tracked contract file
17
+ // (CLAUDE.md/AGENTS.md) the system block folds into is marked `skip-worktree` so that block never stages.
18
+ // The dogfood becomes invisible to collaborators, trading away git-derived spec version history ([[source-of-truth]]).
19
+ private?: boolean
20
+ // which harness targets `spex materialize` delivers into — native ids ('claude'|'codex') or a {plugin:"<folder>"}
21
+ // bundle; resolved + validated by [[harness-select]] (harness-select.ts). Default (omitted): all native harnesses.
22
+ harnesses?: (string | { plugin?: string })[]
12
23
  dashboard?: {
13
24
  apiUrl?: string // the per-project backend the board proxies to (read frontend-side; see api-endpoint)
14
25
  title?: string // override for the browser-tab name (default: the repo-root basename; see tab-title)
15
26
  icon?: string // the browser-tab favicon: an emoji ("🔭") or an Iconify name ("mdi:rocket-launch"); see tab-icon
16
27
  }
17
28
  sessions?: {
18
- maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 6; see sessions.ts maxActive)
19
- claudeCmd?: string // 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.
20
- codexCmd?: string // worker launcher for Codex (default 'codex --yolo'); env SPEXCODE_CODEX_CMD overrides. Same host-path rule.
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.
32
+ // named launcher profiles: a session picks ONE by name at create time ([[launcher-select]]), fixing both
33
+ // its harness AND its exact launch command; the chosen NAME is persisted on the record so resume reuses the
34
+ // same auth. `harness` defaults to 'claude'. Host-specific `cmd`s (abs wrapper paths) belong in the
35
+ // gitignored spexcode.local.json — the name is portable, the cmd is a machine fact.
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)
21
38
  }
22
39
  serve?: {
23
40
  // public-exposure config for `spex serve --public` (resolved gateway-side; see [[public-mode]] / gateway.ts).
@@ -28,11 +45,15 @@ type Config = {
28
45
  tls?: { cert?: string; key?: string } // PATHS to your own cert/key; omit for a cached self-signed default
29
46
  }
30
47
  }
48
+ issues?: {
49
+ enabled?: boolean // the [[local-issues]] issues-workflow on/off switch (default ON). OFF silences the post-merge nudge + hides the dashboard view; flip with `spex issues on|off`. (Pre-rename `proposals.enabled` still reads — localIssues.ts issuesEnabled.)
50
+ }
31
51
  }
32
52
  // the resolved LAYOUT convention — main/mainBranch/branchPrefix filled to defaults. `dashboard`, `sessions`,
33
- // and `serve` are frontend/runtime concerns (read separately via readConfig; see api-endpoint / sessions.ts
34
- // maxActive / gateway.ts), NOT layout fields, so they stay out of the convention rather than forcing a default.
35
- type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve'>>
53
+ // `serve`, `harnesses`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig
54
+ // preset by init.ts at seed time, harnesses by [[harness-select]]; see api-endpoint / sessions.ts maxActive /
55
+ // gateway.ts), NOT layout fields, so they stay out of the convention rather than forcing a default.
56
+ type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'private'>>
36
57
 
37
58
  export type Worktree = {
38
59
  path: string; branch: string | null; node: string | null
@@ -41,17 +62,26 @@ export type Worktree = {
41
62
  }
42
63
  export type Layout = { main: string; convention: Convention; worktrees: Worktree[] }
43
64
 
44
- function readJsonOr(p: string): any {
65
+ // Read an OPTIONAL JSON config file. An ABSENT file is the legitimate default (return {}); a
66
+ // PRESENT-but-malformed one is a user error we must NOT swallow — a typo would otherwise silently
67
+ // drop every tuned setting the file holds (lint budgets, launchers, layout) and revert to defaults
68
+ // with no diagnostic. Fail LOUD, naming the file and the parse error, so the author sees what broke.
69
+ export function readJsonConfig(p: string): any {
45
70
  if (!existsSync(p)) return {}
46
- try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return {} }
71
+ try { return JSON.parse(readFileSync(p, 'utf8')) }
72
+ catch (e) {
73
+ const err = new Error(`malformed ${p}: ${(e as Error).message}\n → its settings were NOT applied. Fix the JSON syntax (an absent file is a fine default; a broken one is not).`)
74
+ err.name = 'ConfigError' // rendered message-only at the CLI boundary (like BackendError), not as a stack dump
75
+ throw err
76
+ }
47
77
  }
48
78
  // committed `spexcode.json` with an OPTIONAL machine-local `spexcode.local.json` layered on top (gitignored).
49
79
  // The local layer is the durable home for HOST-SPECIFIC values that must never be committed — e.g. an
50
80
  // absolute worker-launcher path (the host-path leak the repo otherwise warns against). Precedence per field:
51
81
  // local over committed; an env var (e.g. SPEXCODE_CLAUDE_CMD) still overrides both at its read site.
52
82
  export function readConfig(root: string): Config {
53
- const committed = readJsonOr(join(root, 'spexcode.json'))
54
- const local = readJsonOr(join(root, 'spexcode.local.json'))
83
+ const committed = readJsonConfig(join(root, 'spexcode.json'))
84
+ const local = readJsonConfig(join(root, 'spexcode.local.json'))
55
85
  const out: any = { ...committed }
56
86
  for (const k of Object.keys(local)) {
57
87
  const b = committed[k], o = local[k]
@@ -105,13 +135,6 @@ export function spexcodeHome(): string {
105
135
  export function encodeProject(root: string): string {
106
136
  return root.replace(/[/.]/g, '-')
107
137
  }
108
- // the project a store groups by = the MAIN checkout root (dirname of the shared git common dir). It resolves
109
- // IDENTICALLY from the main checkout OR any linked worktree, so the board (running at main) and a hook
110
- // (running in a worktree) agree on the key — unlike `git rev-parse --show-toplevel`, which in a worktree is
111
- // the worktree path and would scatter a session under a per-worktree key the board never reads.
112
- export function projectKey(): string {
113
- return encodeProject(dirname(gitCommonDir()))
114
- }
115
138
  // this project's per-PROJECT runtime tier — the materialized hook manifest + content-hash marker (and the
116
139
  // gate's lock) — living alongside sessions/ under the SAME global per-project dir, so NOTHING SpexCode renders
117
140
  // stays in the worktree (not even the manifest; the worktree holds only the harness-discovered CLAUDE.md/
@@ -135,18 +158,37 @@ export function sessionArtifactPath(id: string, name: string): string { return j
135
158
  // with sed and never needs jq. Read here for the overlay; sessions.ts owns the full typed read/write.
136
159
  export type RawRecord = {
137
160
  session_id: string; governed: boolean; worktree_path: string; branch: string | null
138
- node: string | null; title: string | null; name: string | null
161
+ node: string | null; title: string | null; name: string | null; parent?: string | null
139
162
  status: string; proposal: string | null; merges: number; note: string | null
140
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
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
141
166
  }
142
167
  // the agent's OWN session id from the environment — the only locator now that the record left the worktree.
143
- // SPEXCODE_SESSION_ID (the GOVERNED record id the launcher bakes in) wins, EXACTLY mirroring the shell hooks'
144
- // `hp_session_id` this matters for codex, whose own CODEX_THREAD_ID is its un-pinnable minted id and does NOT
145
- // equal the governed record id, so resolving the harness var first would point a governed codex agent's own
146
- // `spex session …` at the wrong/nonexistent record. Else each adapter's env var (Claude CLAUDE_CODE_SESSION_ID
147
- // already == the record id; Codex CODEX_THREAD_ID for a self-launched, non-governed agent). No worktree
148
- // fallback. (sessions.ts's `ownSessionId` delegates here; spec-yatsu reads it to resolve the current node.)
168
+ // Three tiers, in order:
169
+ // (1) a harness's per-thread env var (`sessionEnvVar`) RESOLVED VIA THE ALIAS when it lands on a governed
170
+ // record (directly, or through that record's `harness_session_id`), that record's SpexCode id is the
171
+ // answer. This MUST win: codex's design-C runs ONE shared per-project app-server whose env carries the
172
+ // FIRST launched session's `SPEXCODE_SESSION_ID`, and the agent's shell tool (its `spex session
173
+ // done/park/ask`) runs INSIDE that app-server process, so `SPEXCODE_SESSION_ID` is contaminated with the
174
+ // wrong session. But codex injects the ACTING thread's id into every spawned command's env as
175
+ // CODEX_THREAD_ID (== codex's `sessionEnvVar`), so the per-thread var aliases to the RIGHT record while
176
+ // the shared `SPEXCODE_SESSION_ID` does not.
177
+ // (2) else `SPEXCODE_SESSION_ID` (the GOVERNED record id the launcher bakes in) — the claude path and the
178
+ // non-shared baseline.
179
+ // (3) else a harness's env var RAW — a self-launched, non-governed agent's own minted id, which has no
180
+ // governed record to alias to (codex CODEX_THREAD_ID / claude CLAUDE_CODE_SESSION_ID). The RAW form must
181
+ // stay BELOW (2): an un-aliased codex thread id is not a record key, so it must never beat a real
182
+ // `SPEXCODE_SESSION_ID`.
183
+ // Claude is UNCHANGED: its `sessionEnvVar` (CLAUDE_CODE_SESSION_ID) already EQUALS its record id, so tier (1)
184
+ // resolves to that very id — the same value `SPEXCODE_SESSION_ID` would have returned; there is no shared
185
+ // app-server to contaminate it. No worktree fallback. (sessions.ts's `ownSessionId` delegates here; spec-yatsu
186
+ // reads it to resolve the current node.)
149
187
  export function envSessionId(): string | null {
188
+ for (const h of HARNESSES) {
189
+ const v = process.env[h.sessionEnvVar]
190
+ if (v && v.trim()) { const r = readAliasedRawRecord(v.trim()); if (r) return r.session_id }
191
+ }
150
192
  const o = process.env.SPEXCODE_SESSION_ID
151
193
  if (o && o.trim()) return o.trim()
152
194
  for (const h of HARNESSES) { const v = process.env[h.sessionEnvVar]; if (v && v.trim()) return v.trim() }
@@ -158,10 +200,10 @@ export function readRawRecord(id: string): RawRecord | null {
158
200
  return raw && typeof raw === 'object' && raw.session_id ? raw as RawRecord : null
159
201
  } catch { return null }
160
202
  }
161
- // resolve a possibly-ALIASED session id to its raw record. A codex hook fires from the shared per-project
162
- // app-server (no SPEXCODE_SESSION_ID in its env), so the id it carries is the codex THREAD id — the payload
163
- // session_id not the SpexCode record id the store is keyed by. Direct id wins; else the one record that
164
- // captured this id as `harness_session_id` (the backend stored it at thread/start, before any tool turn).
203
+ // resolve a possibly-ALIASED session id to its raw record. A codex hook or spawned command can carry the codex
204
+ // THREAD id payload session_id / CODEX_THREAD_ID — not the SpexCode record id the store is keyed by. Direct id
205
+ // wins; else the one record that captured this id as `harness_session_id` (the backend stored it at thread/start,
206
+ // before any tool turn).
165
207
  // Null when neither resolves. Mirrors the shell `hp_store_dir` alias grep — one resolution rule, both layers.
166
208
  export function readAliasedRawRecord(id: string): RawRecord | null {
167
209
  const direct = readRawRecord(id)
@@ -2,6 +2,7 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { repoRoot, stagedFiles, git } from './git.js'
4
4
  import { loadSpecs } from './specs.js'
5
+ import { readJsonConfig } from './layout.js'
5
6
 
6
7
  export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
7
8
 
@@ -14,6 +15,7 @@ export type LintConfig = {
14
15
  maxChildren: number // breadth budget: warn at >= this many direct children
15
16
  driftErrorThreshold: number// commit-local gate HARD-BLOCKS a commit touching a node >= this many commits behind
16
17
  maxOwners: number // warn when a file is governed (code:) by > this many nodes
18
+ scenarioTags: string[] // the closed vocabulary a yatsu scenario's `tags:` must draw from; extend it to mint a new tag
17
19
  }
18
20
  const DEFAULT_CONFIG: LintConfig = {
19
21
  governedRoots: ['spec-dashboard/src', 'spec-cli/src'],
@@ -24,15 +26,13 @@ const DEFAULT_CONFIG: LintConfig = {
24
26
  maxChildren: 8,
25
27
  driftErrorThreshold: 3,
26
28
  maxOwners: 3,
29
+ scenarioTags: ['frontend-e2e', 'backend-api', 'cli', 'desktop', 'mobile'],
27
30
  }
28
31
  export function loadConfig(root: string): LintConfig {
29
- try {
30
- const raw = JSON.parse(readFileSync(join(root, 'spexcode.json'), 'utf8'))
31
- const c = raw?.lint ?? {}
32
- return { ...DEFAULT_CONFIG, ...c, altitude: { ...DEFAULT_CONFIG.altitude, ...(c.altitude ?? {}) } }
33
- } catch {
34
- return DEFAULT_CONFIG // no file (or unreadable) → tuned defaults; lint is the same as before.
35
- }
32
+ // Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
33
+ // silently reverting the author's budgets to defaults and green-washing the very warnings they tuned.
34
+ const c = readJsonConfig(join(root, 'spexcode.json'))?.lint ?? {}
35
+ return { ...DEFAULT_CONFIG, ...c, altitude: { ...DEFAULT_CONFIG.altitude, ...(c.altitude ?? {}) } }
36
36
  }
37
37
 
38
38
  // a minimal glob → RegExp anchored to the full repo-relative path: `**` = any dirs, `*` = within a segment.
@@ -178,9 +178,11 @@ export async function specLint(): Promise<Finding[]> {
178
178
  out.push({ level: 'warn', rule: 'owners', msg: `${over.length} file(s) are governed by > ${cfg.maxOwners} nodes — each holds more separately-specified functionality than one file should. Worst: ${top}. SPLIT the file so each governor owns its own module (or merge the nodes, or give it a single foundation owner + related:).` })
179
179
  }
180
180
 
181
- // drift: a governed file has commits NOT yet reflected in its spec. Rigorous by git ancestry —
182
- // loadSpecs computes `driftFiles` via `git rev-list <spec's last version>..HEAD -- <file>` (see
183
- // commitsSince in git.ts), so each warning is "N commit(s) ahead", not a timestamp guess.
181
+ // drift: a governed file has commits NOT yet reflected in its spec. Judged by true git ancestry —
182
+ // loadSpecs computes `driftFiles` via driftFor() over the one cached driftIndex walk (git.ts): a
183
+ // commit to the file counts iff it is NOT reachable from the spec's latest version (in-memory
184
+ // parent-edge reachability, the equivalent of `rev-list <version>..HEAD -- <file>`), never a
185
+ // log-position or timestamp guess.
184
186
  for (const s of specs) {
185
187
  for (const d of s.driftFiles)
186
188
  out.push({ level: 'warn', rule: 'drift', spec: s.id, file: d.file, msg: `${d.file} is ${d.behind} commit(s) ahead of spec '${s.id}' (v${s.version}) — may be stale` })
@@ -0,0 +1,28 @@
1
+ import type { Server } from 'node:net'
2
+
3
+ // @@@ listenOrExit - the shared "I own this port; if I cannot bind it, I have failed" contract for the two
4
+ // public-port listeners: the supervisor's raw-TCP proxy (supervise.ts) and the dashboard/public gateway
5
+ // (gateway.ts). A bind failure is the ONE thing neither may survive — it is the opposite of the keep-serving
6
+ // process guard, which rides out transient throws once the port is already held. So instead of leaving the
7
+ // listen error unhandled (under `serve` the supervisor's uncaughtException guard would SWALLOW it into a
8
+ // portless zombie on a random child port; under `dashboard`, with no guard, it would crash with a raw stack),
9
+ // we attach one handler that fails loudly the same way on both surfaces: name the busy port and the repair,
10
+ // reap any child booted for this bind so none is orphaned, and exit non-zero.
11
+ //
12
+ // http.Server / https.Server both extend net.Server, so this one signature covers every caller.
13
+ export function listenOrExit(
14
+ server: Server,
15
+ port: number,
16
+ opts: { host?: string; label: string; cleanup?: () => void; onListen: () => void },
17
+ ): void {
18
+ server.once('error', (err: NodeJS.ErrnoException) => {
19
+ opts.cleanup?.()
20
+ const why = err.code === 'EADDRINUSE' ? `port ${port} is already in use`
21
+ : err.code === 'EACCES' ? `permission denied binding port ${port}`
22
+ : err.code ?? err.message
23
+ console.error(`spec-cli: ${opts.label} cannot bind — ${why}. Free :${port} (e.g. lsof -i :${port}) or pick another port, then retry.`)
24
+ process.exit(1)
25
+ })
26
+ if (opts.host) server.listen(port, opts.host, opts.onListen)
27
+ else server.listen(port, opts.onListen)
28
+ }