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.
- package/README.md +86 -25
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +172 -119
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +60 -19
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +175 -12
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +360 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +272 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +250 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +683 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- 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
|
+
}
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -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
|
|
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 `
|
|
34
|
-
//
|
|
35
|
-
|
|
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
|
-
|
|
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')) }
|
|
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 =
|
|
54
|
-
const local =
|
|
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
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
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
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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)
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
182
|
-
// loadSpecs computes `driftFiles` via
|
|
183
|
-
//
|
|
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
|
+
}
|