spexcode 0.4.1 → 0.5.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 +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +110 -18
- package/spec-cli/src/claude-headless.ts +271 -0
- package/spec-cli/src/cli.ts +112 -38
- package/spec-cli/src/client.ts +42 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +115 -56
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +295 -39
- package/spec-cli/src/help.ts +68 -28
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +158 -87
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +6 -5
- package/spec-cli/src/lint.ts +25 -121
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/message-stream.ts +147 -0
- package/spec-cli/src/opencode-headless.ts +95 -0
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/pi-headless.ts +195 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +14 -0
- package/spec-cli/src/sessions.ts +211 -75
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +12 -7
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +10 -2
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @@@ shim-runtime - the ONE shared runtime every GENERATIVE per-session shim embeds ([[shim-runtime]]).
|
|
2
|
+
// pi's extension and opencode's plugin (and any future in-process harness shim) are thin hosts: they declare
|
|
3
|
+
// an event-name mapping and bind their host's API; the machinery they share — claude-shaped payload synthesis
|
|
4
|
+
// into dispatch.sh, the block-verdict parse, the rendezvous socket server, tool-input normalization — is THIS
|
|
5
|
+
// module's generated chunk, embedded verbatim by each generator. One source of truth: the deliver-confirm and
|
|
6
|
+
// stop-gate-rejection bugs were each fixed in only one of the two hand-written copies this replaces; a fix
|
|
7
|
+
// here reaches every generative shim at the next materialize.
|
|
8
|
+
//
|
|
9
|
+
// The chunk is PLAIN JavaScript (no type annotations, no non-builtin imports): pi loads its shim as a native
|
|
10
|
+
// .ts extension, opencode as a project plugin, and tests import the generated file directly under node — the
|
|
11
|
+
// lowest common denominator is untyped ESM against node builtins. Identifiers are __spex-prefixed so a host
|
|
12
|
+
// section can never collide with them.
|
|
13
|
+
|
|
14
|
+
// the embedded runtime chunk. `harnessId` is baked as dispatch.sh's argv[1] (the deterministic shell-side
|
|
15
|
+
// harness detector); `dispatch`/`spex` are the absolute paths materialize bakes into every shim — the
|
|
16
|
+
// 'dispatch.sh' substring doubles as the identity stamp cleanHarness gates removal on.
|
|
17
|
+
export function shimRuntimeSource(harnessId: string, dispatch: string, spex: string): string {
|
|
18
|
+
return `// ---- spexcode shared shim runtime (embedded from spec-cli/src/shim-runtime.ts — edit THERE) ----
|
|
19
|
+
import { spawn as __spexSpawn } from "node:child_process"
|
|
20
|
+
import { createServer as __spexCreateServer } from "node:net"
|
|
21
|
+
import { unlinkSync as __spexUnlink } from "node:fs"
|
|
22
|
+
|
|
23
|
+
const DISPATCH = ${JSON.stringify(dispatch)}
|
|
24
|
+
const SPEX = ${JSON.stringify(spex)}
|
|
25
|
+
const HARNESS = ${JSON.stringify(harnessId)}
|
|
26
|
+
|
|
27
|
+
// cfg: { sessionId: () => string, cwd?: () => string } — the two identity accessors a host binds; everything
|
|
28
|
+
// else is shared machinery.
|
|
29
|
+
const spexShimRuntime = (cfg) => {
|
|
30
|
+
const cwd = cfg.cwd || (() => process.cwd())
|
|
31
|
+
|
|
32
|
+
// feed ONE claude-shaped payload ({ session_id, cwd, hook_event_name, ...extra }) to dispatch.sh, the
|
|
33
|
+
// harness id baked as argv[1]. Payload key order matters for tool events: a host must put agent_id (when
|
|
34
|
+
// present) BEFORE tool_input because harness.sh's hp_is_subagent checks keys only before tool_input. Resolves with the exit
|
|
35
|
+
// code + both streams for the verdict helpers below; never rejects.
|
|
36
|
+
const dispatchEvent = (event, extra) => new Promise((resolve) => {
|
|
37
|
+
let out = "", err = ""
|
|
38
|
+
let child
|
|
39
|
+
try {
|
|
40
|
+
child = __spexSpawn("bash", [DISPATCH, HARNESS, event], {
|
|
41
|
+
cwd: cwd(), env: { ...process.env, SPEX }, stdio: ["pipe", "pipe", "pipe"], timeout: 600_000,
|
|
42
|
+
})
|
|
43
|
+
} catch (e) { resolve({ code: 1, out, err: String(e) }); return }
|
|
44
|
+
child.stdout.on("data", (d) => { out += d })
|
|
45
|
+
child.stderr.on("data", (d) => { err += d })
|
|
46
|
+
child.on("error", (e) => resolve({ code: 1, out, err: err || String(e) }))
|
|
47
|
+
child.on("close", (code) => resolve({ code: code == null ? 1 : code, out, err }))
|
|
48
|
+
// a handler may exit without draining stdin (fast-fail scripts, a missing dispatch file) — the write then
|
|
49
|
+
// EPIPEs; that must never throw into the host's event handler, the child's exit code still answers.
|
|
50
|
+
child.stdin.on("error", () => { /* child exited before consuming stdin — fine */ })
|
|
51
|
+
try { child.stdin.end(JSON.stringify({ session_id: cfg.sessionId(), cwd: cwd(), hook_event_name: event, ...(extra || {}) })) } catch { /* same */ }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// the ONE block contract: dispatch.sh signals a block by EXIT 2 (a handler's stdout
|
|
55
|
+
// {"decision":"block","reason":…} JSON is mapped to exit 2 by dispatch itself, stderr staying empty; a
|
|
56
|
+
// bare exit-2 handler writes its reason on stderr instead). codex's stderr bridge is codex's own native
|
|
57
|
+
// protocol and lives in dispatch.sh — not here.
|
|
58
|
+
const blocked = (r) => r.code === 2
|
|
59
|
+
|
|
60
|
+
// the human-readable rejection, extracted in contract order — never the raw wire JSON (an escaped-\\n payload
|
|
61
|
+
// turned the stop-gate's teaching menu into one unreadable line on one harness before this was shared):
|
|
62
|
+
// 1. a decision:block JSON line on stdout, strictly parsed (the designed channel);
|
|
63
|
+
// 2. several handlers' stdout objects can arrive GLUED without separators — regex the reason string out
|
|
64
|
+
// (JSON.parse of the string literal unescapes it);
|
|
65
|
+
// 3. stderr — the channel a bare exit-2 handler writes;
|
|
66
|
+
// 4. the caller's fallback.
|
|
67
|
+
const blockReason = (r, fallback) => {
|
|
68
|
+
for (const line of (r.out || "").split("\\n")) {
|
|
69
|
+
const s = line.trim()
|
|
70
|
+
if (!s.startsWith("{")) continue
|
|
71
|
+
try {
|
|
72
|
+
const o = JSON.parse(s)
|
|
73
|
+
if (o && o.decision === "block" && typeof o.reason === "string" && o.reason) return o.reason
|
|
74
|
+
} catch { /* not a lone JSON line — the glued fallback below */ }
|
|
75
|
+
}
|
|
76
|
+
if (/"decision"\\s*:\\s*"block"/.test(r.out || "")) {
|
|
77
|
+
const m = (r.out || "").match(/"reason"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"/)
|
|
78
|
+
if (m) { try { return JSON.parse('"' + m[1] + '"') } catch { /* malformed escape — fall through */ } }
|
|
79
|
+
}
|
|
80
|
+
return (r.err || "").trim() || fallback
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// the Stop dispatch with the gate's LOOP-TERMINATION bit. Claude's native Stop payload carries
|
|
84
|
+
// stop_hook_active=true when the stop happens inside a continuation a Stop-hook block itself forced, and
|
|
85
|
+
// the stop-gate's escape paths key on exactly that bit to guarantee the loop ends (auto-declare /
|
|
86
|
+
// downgrade-to-ask instead of a fresh block). A generative host has no native bit, so the runtime supplies
|
|
87
|
+
// it: false on a natural stop, true on the settle that follows a blocked one, reset by the first allowed
|
|
88
|
+
// stop. Without it every settle reads as a FIRST stop and a gate that can never pass (e.g. the commit gate
|
|
89
|
+
// on a 0-commit worktree) blocks forever. On block, the host's inject re-enters the gate's reason as the
|
|
90
|
+
// continuation; an inject that cannot land is caught LOUD on stderr — never thrown into the host, never
|
|
91
|
+
// silent. stopPending exposes whether the
|
|
92
|
+
// last Stop verdict was a block still awaiting its continuation's allow — the state a host's backstop
|
|
93
|
+
// binding (pi's agent_settled) keys on to consume exactly one pending blocked stop, never duplicating a
|
|
94
|
+
// naturally-allowed dispatch.
|
|
95
|
+
let stopBlocked = false
|
|
96
|
+
const stopPending = () => stopBlocked
|
|
97
|
+
const dispatchStop = async (inject, fallback) => {
|
|
98
|
+
const r = await dispatchEvent("Stop", { stop_hook_active: stopBlocked })
|
|
99
|
+
if (blocked(r)) {
|
|
100
|
+
stopBlocked = true
|
|
101
|
+
try { await inject(blockReason(r, fallback)) } catch (e) {
|
|
102
|
+
console.error("spexcode: a blocked stop's continuation could not be re-injected: " + String(e))
|
|
103
|
+
}
|
|
104
|
+
} else stopBlocked = false
|
|
105
|
+
return r
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// normalize a host tool-input onto the claude accessor shape: mirror the host's file-path spelling onto
|
|
109
|
+
// file_path (the key harness.sh and every claude-family handler read), dropping the host spelling.
|
|
110
|
+
const toolInput = (input, fileAlias) => {
|
|
111
|
+
const args = { ...(input && typeof input === "object" ? input : {}) }
|
|
112
|
+
if (fileAlias && args[fileAlias] !== undefined && args.file_path === undefined) {
|
|
113
|
+
args.file_path = args[fileAlias]
|
|
114
|
+
delete args[fileAlias]
|
|
115
|
+
}
|
|
116
|
+
return args
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// the per-session rendezvous control socket: bind a line-JSON server on CLAUDE_BG_RENDEZVOUS_SOCK (handed
|
|
120
|
+
// by every ownsRendezvous launch; a self-launched bare harness has no env → no server) speaking the
|
|
121
|
+
// reclaude mini-protocol, so claude's deliverViaRendezvous and socket-listener liveness work UNCHANGED.
|
|
122
|
+
// MULTI-connection (unlike reclaude's daemon): a session-liveness probe connect can never kick a concurrent
|
|
123
|
+
// delivery, so the sender's atomic reply+repaint chunk always resolves on its own connection. The data
|
|
124
|
+
// handler is deliberately SYNCHRONOUS — a chunk's lines parse in one pass and repaint-done (the in-order
|
|
125
|
+
// parse barrier) flushes before any other event can run: confirmation means PARSED, not processed; the
|
|
126
|
+
// injection (a whole model turn on some hosts) runs BEHIND the confirm. A known-unable inject answers
|
|
127
|
+
// reply-rejected BEFORE repaint-done so the sender fails loud instead of confirming a prompt that can
|
|
128
|
+
// never land; a LATE inject failure best-effort reply-rejects (the sender has usually resolved and gone).
|
|
129
|
+
const serveRendezvous = (inject, opts) => {
|
|
130
|
+
const sock = (process.env.CLAUDE_BG_RENDEZVOUS_SOCK || "").trim()
|
|
131
|
+
if (!sock) return null
|
|
132
|
+
try { __spexUnlink(sock) } catch { /* no stale socket — fine */ }
|
|
133
|
+
const reject = (c) => { try { c.write(JSON.stringify({ type: "reply-rejected" }) + "\\n") } catch { /* peer gone */ } }
|
|
134
|
+
const server = __spexCreateServer((c) => {
|
|
135
|
+
let buf = ""
|
|
136
|
+
c.on("error", () => { /* probes disconnect abruptly — expected */ })
|
|
137
|
+
c.on("data", (d) => {
|
|
138
|
+
buf += d.toString("utf8")
|
|
139
|
+
let nl
|
|
140
|
+
while ((nl = buf.indexOf("\\n")) >= 0) {
|
|
141
|
+
const line = buf.slice(0, nl)
|
|
142
|
+
buf = buf.slice(nl + 1)
|
|
143
|
+
let msg
|
|
144
|
+
try { msg = JSON.parse(line) } catch { continue }
|
|
145
|
+
if (msg && msg.type === "reply" && typeof msg.text === "string") {
|
|
146
|
+
if (opts && opts.canInject && !opts.canInject()) { reject(c); continue }
|
|
147
|
+
try {
|
|
148
|
+
const p = inject(msg.text)
|
|
149
|
+
if (p && typeof p.catch === "function") p.catch(() => reject(c))
|
|
150
|
+
} catch { reject(c) }
|
|
151
|
+
} else if (msg && msg.type === "repaint") {
|
|
152
|
+
try { c.write(JSON.stringify({ type: "repaint-done" }) + "\\n") } catch { /* peer gone */ }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
server.on("error", (e) => console.error("spexcode: rendezvous socket failed to bind at " + sock + ": " + String(e)))
|
|
158
|
+
server.listen(sock)
|
|
159
|
+
server.unref() // never hold the host process open for our socket; the harness's own loop keeps it alive
|
|
160
|
+
return { close: () => {
|
|
161
|
+
try { server.close() } catch { /* already closed */ }
|
|
162
|
+
try { __spexUnlink(sock) } catch { /* already gone */ }
|
|
163
|
+
} }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { dispatchEvent, dispatchStop, stopPending, blocked, blockReason, toolInput, serveRendezvous }
|
|
167
|
+
}
|
|
168
|
+
// ---- end spexcode shared shim runtime ----`
|
|
169
|
+
}
|
|
@@ -227,6 +227,48 @@ const CODEX_BUILT_IN: ReadonlyArray<readonly [string, string]> = [
|
|
|
227
227
|
// the user's saved prompts in `$CODEX_HOME/prompts/*.md` (each filename becomes `/<name>`, codex's custom-
|
|
228
228
|
// prompt convention). Like the Claude builder it NEVER guesses — plugin commands that aren't readable as
|
|
229
229
|
// simple files contribute nothing. Presentation order: built-ins first (codex's enum order), then prompts.
|
|
230
|
+
// @@@ PI_BUILT_IN seed - pi's `/` menu, extracted from the INSTALLED pi's own command table
|
|
231
|
+
// (dist/core/slash-commands.js, @earendil-works/pi-coding-agent 0.80.10) — discovered, not guessed, same
|
|
232
|
+
// discipline as the claude/codex captures; to refresh for a new pi, re-read that module.
|
|
233
|
+
const PI_BUILT_IN: ReadonlyArray<readonly [string, string]> = [
|
|
234
|
+
['settings', 'Open settings menu'],
|
|
235
|
+
['model', 'Select model (opens selector UI)'],
|
|
236
|
+
['scoped-models', 'Enable/disable models for Ctrl+P cycling'],
|
|
237
|
+
['export', 'Export session (HTML default, or specify path: .html/.jsonl)'],
|
|
238
|
+
['import', 'Import and resume a session from a JSONL file'],
|
|
239
|
+
['share', 'Share session as a secret GitHub gist'],
|
|
240
|
+
['copy', 'Copy last agent message to clipboard'],
|
|
241
|
+
['name', 'Set session display name'],
|
|
242
|
+
['session', 'Show session info and stats'],
|
|
243
|
+
['changelog', 'Show changelog entries'],
|
|
244
|
+
['hotkeys', 'Show all keyboard shortcuts'],
|
|
245
|
+
['fork', 'Create a new fork from a previous user message'],
|
|
246
|
+
['clone', 'Duplicate the current session at the current position'],
|
|
247
|
+
['tree', 'Navigate session tree (switch branches)'],
|
|
248
|
+
['trust', 'Save project trust decision for future sessions'],
|
|
249
|
+
['login', 'Configure provider authentication'],
|
|
250
|
+
['logout', 'Remove provider authentication'],
|
|
251
|
+
['new', 'Start a new session'],
|
|
252
|
+
['compact', 'Manually compact the session context'],
|
|
253
|
+
['resume', 'Resume a different session'],
|
|
254
|
+
['reload', 'Reload keybindings, extensions, skills, prompts, themes, and context files'],
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
// @@@ piSlashCommands - pi's `/` menu, computed the way pi computes its own: built-ins (above) + prompt
|
|
258
|
+
// templates (`~/.pi/agent/prompts/*.md` global, `.pi/prompts/*.md` project — each filename becomes `/<name>`)
|
|
259
|
+
// + skills (`/skill:<name>` is pi's spelling, but the bare name is what our dropdown wants). Best-effort like
|
|
260
|
+
// the codex builder: what isn't readable as simple files contributes nothing.
|
|
261
|
+
export function piSlashCommands(): SlashCommand[] {
|
|
262
|
+
const repo = repoRoot()
|
|
263
|
+
const all: SlashCommand[] = []
|
|
264
|
+
for (const [name, description] of PI_BUILT_IN) all.push({ name, description, source: 'built-in' })
|
|
265
|
+
scanCommands(join(homedir(), '.pi', 'agent', 'prompts'), 'user', all)
|
|
266
|
+
scanCommands(join(repo, '.pi', 'prompts'), 'project', all)
|
|
267
|
+
scanSkills(join(homedir(), '.pi', 'agent', 'skills'), all)
|
|
268
|
+
scanSkills(join(repo, '.pi', 'skills'), all)
|
|
269
|
+
return dedupeSort(all)
|
|
270
|
+
}
|
|
271
|
+
|
|
230
272
|
export function codexSlashCommands(): SlashCommand[] {
|
|
231
273
|
const all: SlashCommand[] = []
|
|
232
274
|
for (const [name, description] of CODEX_BUILT_IN) all.push({ name, description, source: 'built-in' })
|
|
@@ -240,3 +282,37 @@ export function codexSlashCommands(): SlashCommand[] {
|
|
|
240
282
|
const prompts = [...seen.values()].filter((c) => c.source !== 'built-in').sort((a, b) => a.name.localeCompare(b.name))
|
|
241
283
|
return [...builtins, ...prompts]
|
|
242
284
|
}
|
|
285
|
+
|
|
286
|
+
// opencode built-ins — a conservative pinned set (opencode 1.18.x TUI commands; its TUI registry is compiled,
|
|
287
|
+
// not file-discoverable, so like the other two capture blocks this is version-specific and re-pinned by hand).
|
|
288
|
+
const OPENCODE_BUILT_IN: ReadonlyArray<readonly [string, string]> = [
|
|
289
|
+
['compact', 'Summarize and compact the current session'],
|
|
290
|
+
['editor', 'Open the external editor for the message'],
|
|
291
|
+
['exit', 'Exit opencode'],
|
|
292
|
+
['export', 'Export the session'],
|
|
293
|
+
['help', 'Show help'],
|
|
294
|
+
['init', 'Create or update AGENTS.md'],
|
|
295
|
+
['models', 'List and switch models'],
|
|
296
|
+
['new', 'Start a new session'],
|
|
297
|
+
['redo', 'Redo a previously undone message'],
|
|
298
|
+
['sessions', 'List and switch sessions'],
|
|
299
|
+
['share', 'Share the current session'],
|
|
300
|
+
['themes', 'List and switch themes'],
|
|
301
|
+
['undo', 'Undo the last message'],
|
|
302
|
+
['unshare', 'Unshare the current session'],
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
// @@@ opencodeSlashCommands - opencode's `/` menu: the pinned built-ins + custom commands discovered the way
|
|
306
|
+
// opencode discovers its own — `.opencode/command(s)/**/*.md` in the project and `~/.config/opencode/command(s)`
|
|
307
|
+
// globally (both spellings are native). Like the other builders it never guesses: what isn't readable as a
|
|
308
|
+
// file contributes nothing.
|
|
309
|
+
export function opencodeSlashCommands(): SlashCommand[] {
|
|
310
|
+
const all: SlashCommand[] = []
|
|
311
|
+
const cfgHome = process.env.OPENCODE_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'opencode')
|
|
312
|
+
for (const d of ['command', 'commands']) scanCommands(join(cfgHome, d), 'user', all)
|
|
313
|
+
const repo = repoRoot()
|
|
314
|
+
for (const d of ['command', 'commands']) scanCommands(join(repo, '.opencode', d), 'project', all)
|
|
315
|
+
for (const [name, description] of OPENCODE_BUILT_IN) all.push({ name, description, source: 'built-in' })
|
|
316
|
+
scanSkills(join(repo, '.opencode', 'skills'), all)
|
|
317
|
+
return dedupeSort(all)
|
|
318
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { closeSync, lstatSync, openSync, readSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { git } from './git.js'
|
|
4
|
+
|
|
5
|
+
export type SourcePolicy = {
|
|
6
|
+
sourceIncludeGlobs: string[] | null
|
|
7
|
+
sourceExcludeGlobs: string[]
|
|
8
|
+
testGlobs: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_TEST_GLOBS = [
|
|
12
|
+
'**/*.test.*',
|
|
13
|
+
'**/*.spec.*',
|
|
14
|
+
'**/test_*.*',
|
|
15
|
+
'**/*_test.*',
|
|
16
|
+
'**/test/**',
|
|
17
|
+
'**/tests/**',
|
|
18
|
+
'**/__tests__/**',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
function globToRe(glob: string): RegExp {
|
|
22
|
+
const body = glob.split(/(\*\*\/|\*\*|\*|\?)/).map((seg) => {
|
|
23
|
+
if (seg === '**/') return '(?:.*/)?'
|
|
24
|
+
if (seg === '**') return '.*'
|
|
25
|
+
if (seg === '*') return '[^/]*'
|
|
26
|
+
if (seg === '?') return '[^/]'
|
|
27
|
+
return seg.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
28
|
+
}).join('')
|
|
29
|
+
return new RegExp(`^${body}$`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isSpexCodeData(path: string): boolean {
|
|
33
|
+
return path === 'spexcode.json' || path === 'spexcode.local.json'
|
|
34
|
+
|| path === '.spec' || path.startsWith('.spec/')
|
|
35
|
+
|| path === '.plugins' || path.startsWith('.plugins/')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isTextWorktreeFile(root: string, path: string): boolean {
|
|
39
|
+
const full = join(root, path)
|
|
40
|
+
let fd: number | null = null
|
|
41
|
+
try {
|
|
42
|
+
if (!lstatSync(full).isFile()) return false
|
|
43
|
+
fd = openSync(full, 'r')
|
|
44
|
+
const sample = Buffer.allocUnsafe(8000)
|
|
45
|
+
const bytes = readSync(fd, sample, 0, sample.length, 0)
|
|
46
|
+
return !sample.subarray(0, bytes).includes(0)
|
|
47
|
+
} catch {
|
|
48
|
+
return false
|
|
49
|
+
} finally {
|
|
50
|
+
if (fd !== null) closeSync(fd)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Source discovery classifies tracked paths and bytes only. Language structure belongs to anchors.ts's
|
|
55
|
+
// adapter registry; adding a language must never add a branch here.
|
|
56
|
+
export function isSourceFile(root: string, path: string, policy: SourcePolicy): boolean {
|
|
57
|
+
if (isSpexCodeData(path)) return false
|
|
58
|
+
if (policy.sourceIncludeGlobs !== null && !policy.sourceIncludeGlobs.some((glob) => globToRe(glob).test(path))) return false
|
|
59
|
+
if (policy.sourceExcludeGlobs.some((glob) => globToRe(glob).test(path))) return false
|
|
60
|
+
if (policy.testGlobs.some((glob) => globToRe(glob).test(path))) return false
|
|
61
|
+
return isTextWorktreeFile(root, path)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function trackedSourceFiles(root: string, roots: string[], policy: SourcePolicy): string[] {
|
|
65
|
+
const out = new Set<string>()
|
|
66
|
+
for (const governedRoot of roots) {
|
|
67
|
+
let listed = ''
|
|
68
|
+
try { listed = git(['-C', root, 'ls-files', '-z', '--', governedRoot]) } catch { continue }
|
|
69
|
+
for (const path of listed.split('\0')) {
|
|
70
|
+
if (path && isSourceFile(root, path, policy)) out.add(path)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...out]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function sourcePolicyDescription(policy: SourcePolicy): string {
|
|
77
|
+
const includes = policy.sourceIncludeGlobs === null ? 'ALL tracked regular text' : `[${policy.sourceIncludeGlobs.join(', ')}]`
|
|
78
|
+
return `includes ${includes}; sourceExcludeGlobs [${policy.sourceExcludeGlobs.join(', ')}]; testGlobs [${policy.testGlobs.join(', ')}] (SpexCode-owned data always excluded)`
|
|
79
|
+
}
|
package/spec-cli/src/specs.ts
CHANGED
|
@@ -141,21 +141,22 @@ function raws(): Raw[] {
|
|
|
141
141
|
// way the sync walk (one ~450ms uninterrupted stretch) did. Same output as raws() — identical push order
|
|
142
142
|
// (pre-order DFS, dir before children) and the same reId — so every caller reads the same nodes; only
|
|
143
143
|
// loadSpecs (already async, on the hot path) uses it, the light one-shot callers keep the sync raws().
|
|
144
|
-
async function walkAsync(dir: string, parent: string | null, acc: Raw[]): Promise<void> {
|
|
144
|
+
async function walkAsync(dir: string, parent: string | null, acc: Raw[], root: string): Promise<void> {
|
|
145
145
|
let myId = parent
|
|
146
146
|
if (existsSync(join(dir, 'spec.md'))) {
|
|
147
147
|
myId = basename(dir)
|
|
148
|
-
const relPath = relative(
|
|
148
|
+
const relPath = relative(root, join(dir, 'spec.md'))
|
|
149
149
|
const { fm, body } = parseFrontmatter(await readFile(join(dir, 'spec.md'), 'utf8'))
|
|
150
150
|
acc.push({ id: myId, parent, relPath, fm, body })
|
|
151
151
|
}
|
|
152
152
|
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
153
|
-
if (e.isDirectory()) await walkAsync(join(dir, e.name), myId, acc)
|
|
153
|
+
if (e.isDirectory()) await walkAsync(join(dir, e.name), myId, acc, root)
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
|
-
async function rawsAsync(): Promise<Raw[]> {
|
|
156
|
+
async function rawsAsync(root: string): Promise<Raw[]> {
|
|
157
157
|
const acc: Raw[] = []
|
|
158
|
-
|
|
158
|
+
const specDir = join(root, '.spec')
|
|
159
|
+
if (existsSync(specDir)) await walkAsync(specDir, null, acc, root)
|
|
159
160
|
reId(acc)
|
|
160
161
|
return acc
|
|
161
162
|
}
|
|
@@ -222,10 +223,14 @@ export function specContent(id: string): { body: string; parts: ReturnType<typeo
|
|
|
222
223
|
return r ? { body: r.body.trim(), parts: parseParts(r.body) } : null
|
|
223
224
|
}
|
|
224
225
|
|
|
225
|
-
|
|
226
|
+
// `root` defaults to the backend's own checkout — the canonical tree. A session worktree may be passed
|
|
227
|
+
// instead ([[source-of-truth]]'s several-checkouts principle at the loader level): its .spec is the
|
|
228
|
+
// branch's pending proposal, so eval surfaces rooted at a session must load the spec tree from the SAME
|
|
229
|
+
// root as their readings/indexes, or a branch-NEW node simply does not exist for them.
|
|
230
|
+
export async function loadSpecs(root: string = ROOT) {
|
|
226
231
|
// both indexes are one cached git walk each and independent — fetch them in parallel (async git, off
|
|
227
232
|
// the event loop). Every node below is then a pure lookup.
|
|
228
|
-
const [idx, didx, allRaws] = await Promise.all([historyIndex(
|
|
233
|
+
const [idx, didx, allRaws] = await Promise.all([historyIndex(root), driftIndex(root), rawsAsync(root)])
|
|
229
234
|
return Promise.all(allRaws.map(async (r) => {
|
|
230
235
|
const h = rowsFor(idx, r.relPath)
|
|
231
236
|
// session = the Session: trailer of the node's latest version; frontmatter `session:` is the fallback.
|
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
// runs the Hono server as a child on a private port (raw piping carries WS upgrades too).
|
|
3
3
|
import net from 'node:net'
|
|
4
4
|
import http from 'node:http'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
5
6
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
6
|
-
import { statSync, readdirSync,
|
|
7
|
+
import { statSync, readdirSync, type Dirent } from 'node:fs'
|
|
7
8
|
import { fileURLToPath } from 'node:url'
|
|
8
9
|
import { dirname, join } from 'node:path'
|
|
9
10
|
import { installProcessGuards } from './resilience.js'
|
|
10
11
|
import { listenOrExit } from './listen.js'
|
|
11
12
|
import { resolvePublicConfig, startGateway, ensureDashboardBuilt, resolveDistDir } from './gateway.js'
|
|
12
13
|
import { tsxBin } from './tsx-bin.js'
|
|
13
|
-
import {
|
|
14
|
+
import { publishEndpoint, dropOwnEndpoint } from './host.js'
|
|
15
|
+
import { repoRoot as servedRepoRoot } from './git.js'
|
|
16
|
+
import { resolveProjectIdentity } from './project-identity.js'
|
|
14
17
|
|
|
15
18
|
// the supervisor OWNS the public port, so it must outlive any transient throw: an uncaught error here is
|
|
16
19
|
// logged and survived, never an exit that closes the port (and the tmux session) and takes the frontend down.
|
|
@@ -40,6 +43,14 @@ const watchRoots = [
|
|
|
40
43
|
join(repoRoot, 'spec-eval', 'src'),
|
|
41
44
|
]
|
|
42
45
|
|
|
46
|
+
// @@@ instance identity - one id for this serve's whole lifetime, minted at supervisor start and handed to
|
|
47
|
+
// every child via env, so the endpoint record and the live backend answer with the SAME identity across
|
|
48
|
+
// zero-downtime reloads. The host gateway ([[host-gateway]]) validates a record by comparing this id (and the
|
|
49
|
+
// served root) against the live /api/instance answer — a recycled port serving a DIFFERENT project or a
|
|
50
|
+
// different serve generation fails the match and is treated as offline, never proxied to.
|
|
51
|
+
const instanceId = randomUUID()
|
|
52
|
+
const projectRoot = servedRepoRoot() // the actual git tree whose source/spec/config the child serves
|
|
53
|
+
|
|
43
54
|
type Backend = { port: number; child: ChildProcess }
|
|
44
55
|
let current: Backend | null = null // which internal port new proxy connections forward to
|
|
45
56
|
let reloading = false // single-flight guard for reload()
|
|
@@ -79,7 +90,7 @@ async function boot(): Promise<Backend | null> {
|
|
|
79
90
|
// process.env.SPEXCODE_API_URL: the env this serve itself inherited may carry ANOTHER project's backend
|
|
80
91
|
// (the exact misroute [[remote-client]]'s ladder exists to kill), and a worker's env is its routing
|
|
81
92
|
// LIFELINE — it must be a deterministic backend-injected fact, not an inheritance gamble.
|
|
82
|
-
const child = spawn(process.execPath, [tsx, entry], { stdio: 'inherit', env: { ...process.env, PORT: String(port), SPEXCODE_API_URL: childApiBase } })
|
|
93
|
+
const child = spawn(process.execPath, [tsx, entry], { stdio: 'inherit', env: { ...process.env, PORT: String(port), SPEXCODE_API_URL: childApiBase, SPEXCODE_INSTANCE_ID: instanceId } })
|
|
83
94
|
// if the ACTIVE backend dies unexpectedly (crash, OOM), restart it so the public port keeps serving.
|
|
84
95
|
// Planned retirement sets current to the NEW child first, so the old child's exit fails this identity
|
|
85
96
|
// check and is ignored. boot()'s ~5s health budget rate-limits any crash loop.
|
|
@@ -115,9 +126,9 @@ async function reload(reason: string): Promise<void> {
|
|
|
115
126
|
// client-side timeout-kill left the client's UPSTREAM socket half-open to the child (the old handler bailed
|
|
116
127
|
// only on `error`, so a clean FIN / a silent drop never reaped the upstream) — 135 leaked conns piled on the
|
|
117
128
|
// child and it looked dead. So a close on either side destroys BOTH (idempotent). The abandoned-but-silent
|
|
118
|
-
// case (no FIN/RST ever arrives) is reaped from the CHILD instead — its
|
|
119
|
-
//
|
|
120
|
-
// is never mistaken for abandoned and cut.
|
|
129
|
+
// case (no FIN/RST ever arrives) is reaped from the CHILD instead — its socket-level reaper (reaper.ts)
|
|
130
|
+
// destroys an idle/stalled socket, whose close then propagates here — so an active WS/SSE (not idle
|
|
131
|
+
// keep-alive) is never mistaken for abandoned and cut.
|
|
121
132
|
const proxy = net.createServer((client) => {
|
|
122
133
|
const target = current
|
|
123
134
|
if (!target) { client.destroy(); return }
|
|
@@ -134,24 +145,35 @@ const proxy = net.createServer((client) => {
|
|
|
134
145
|
client.pipe(up); up.pipe(client)
|
|
135
146
|
})
|
|
136
147
|
|
|
137
|
-
// @@@ endpoint record - this project's live backend endpoint,
|
|
148
|
+
// @@@ endpoint record - this project's live backend endpoint, published into the per-project runtime tier
|
|
138
149
|
// (~/.spexcode/projects/<enc>/backend.json) only AFTER the public bind succeeds. It's what lets a bare
|
|
139
150
|
// `spex` run from this project's tree find ITS OWN backend instead of an env URL inherited from another
|
|
140
|
-
// project's ([[remote-client]]'s resolution ladder)
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
151
|
+
// project's ([[remote-client]]'s resolution ladder), and what the host gateway ([[host-gateway]]) reconciles
|
|
152
|
+
// its project list from. The write is ATOMIC (tmp + rename) and carries the serve's actual git toplevel +
|
|
153
|
+
// resolved identity — so a reader can VALIDATE the record against the live /api/instance answer,
|
|
154
|
+
// never just trust a URL. Readers health-probe before trusting, so a crashed serve leaves at worst a dead
|
|
155
|
+
// record that is ignored — never followed. The recorded URL is the LOOPBACK face local agents reach (equals
|
|
156
|
+
// the public port when public mode is off), never the password-gated gateway. A linked worktree therefore
|
|
157
|
+
// gets its own encoded slot and can never overwrite the main checkout's endpoint.
|
|
144
158
|
function recordEndpoint(url: string): void {
|
|
145
159
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
160
|
+
publishEndpoint({
|
|
161
|
+
version: 2,
|
|
162
|
+
url,
|
|
163
|
+
pid: process.pid,
|
|
164
|
+
instanceId,
|
|
165
|
+
root: projectRoot,
|
|
166
|
+
identity: resolveProjectIdentity(projectRoot, projectRoot),
|
|
167
|
+
startedAt: new Date().toISOString(),
|
|
168
|
+
})
|
|
148
169
|
} catch (e) {
|
|
149
170
|
console.error(`[supervisor] could not record the backend endpoint (${(e as Error).message}) — cwd-based \`spex\` discovery won't find this backend`)
|
|
150
171
|
}
|
|
151
172
|
}
|
|
152
|
-
// best-effort removal on a clean stop, only if the record is OURS
|
|
173
|
+
// best-effort removal on a clean stop, only if the record is OURS — matched by instanceId, so a newer serve
|
|
174
|
+
// that already overwrote the record (or another project's) is never deleted by a retiring one.
|
|
153
175
|
function dropEndpoint(): void {
|
|
154
|
-
try {
|
|
176
|
+
try { dropOwnEndpoint(instanceId, projectRoot) } catch { /* not ours / already gone */ }
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
const shutdown = () => { dropEndpoint(); try { current?.child.kill('SIGTERM') } catch { /* */ } process.exit(0) }
|
package/spec-cli/src/tree.ts
CHANGED
|
@@ -12,9 +12,10 @@ export type TreeNode = {
|
|
|
12
12
|
version?: number
|
|
13
13
|
drift?: number
|
|
14
14
|
ghost?: boolean
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
reviewSummary?: {
|
|
16
|
+
issues?: { open: number }
|
|
17
|
+
evals?: { stalePass: number; staleFail: number }
|
|
18
|
+
}
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export type TreeOpts = { node?: string; depth?: number; color?: boolean }
|
|
@@ -27,9 +28,8 @@ const STATUS_ANSI: Record<string, string> = { merged: '32', active: '36', drift:
|
|
|
27
28
|
// board `evals` is already latest-per-scenario, so this is a straight filter — the same freshness
|
|
28
29
|
// axis the dashboard's grey ✓/✗ badges read (score.jsx readingScore).
|
|
29
30
|
function staleYatsu(n: TreeNode): number {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return n.scenarios.filter((s) => { const r = latest.get(s.name); return r && r.fresh === false }).length
|
|
31
|
+
const summary = n.reviewSummary?.evals
|
|
32
|
+
return summary ? summary.stalePass + summary.staleFail : 0
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function childrenIndex(nodes: TreeNode[]): Map<string | null, TreeNode[]> {
|
|
@@ -67,7 +67,7 @@ export function renderTree(nodes: TreeNode[], opts: TreeOpts = {}): string {
|
|
|
67
67
|
if (n.drift) parts.push(c('33', `drift:${n.drift}`))
|
|
68
68
|
const stale = staleYatsu(n)
|
|
69
69
|
if (stale) parts.push(c('90', `stale:${stale}`))
|
|
70
|
-
if (n.
|
|
70
|
+
if (n.reviewSummary?.issues?.open) parts.push(c('31', `issues:${n.reviewSummary.issues.open}`))
|
|
71
71
|
return parts.length ? ' ' + parts.join(' ') : ''
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -109,7 +109,7 @@ export function treeJson(nodes: TreeNode[], opts: TreeOpts = {}): object[] {
|
|
|
109
109
|
const pruned = opts.depth !== undefined && depth >= opts.depth
|
|
110
110
|
return {
|
|
111
111
|
id: n.id, title: n.title, status: n.status, version: n.version ?? 0,
|
|
112
|
-
drift: n.drift ?? 0, staleYatsu: staleYatsu(n), openIssues: n.
|
|
112
|
+
drift: n.drift ?? 0, staleYatsu: staleYatsu(n), openIssues: n.reviewSummary?.issues?.open ?? 0,
|
|
113
113
|
...(n.ghost ? { ghost: true } : {}),
|
|
114
114
|
children: pruned ? kids.map((k) => k.id) : kids.map((k) => shape(k, depth + 1)),
|
|
115
115
|
}
|