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.
Files changed (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +110 -18
  7. package/spec-cli/src/claude-headless.ts +271 -0
  8. package/spec-cli/src/cli.ts +112 -38
  9. package/spec-cli/src/client.ts +42 -11
  10. package/spec-cli/src/doctor.ts +174 -36
  11. package/spec-cli/src/gateway-auth.ts +194 -0
  12. package/spec-cli/src/gateway-hub.ts +360 -0
  13. package/spec-cli/src/gateway.ts +27 -19
  14. package/spec-cli/src/git.ts +12 -5
  15. package/spec-cli/src/graph.ts +54 -43
  16. package/spec-cli/src/graphStream.ts +265 -34
  17. package/spec-cli/src/guide.ts +115 -56
  18. package/spec-cli/src/harness-select.ts +20 -6
  19. package/spec-cli/src/harness.ts +295 -39
  20. package/spec-cli/src/help.ts +68 -28
  21. package/spec-cli/src/host.ts +610 -0
  22. package/spec-cli/src/identity-presets.d.ts +13 -0
  23. package/spec-cli/src/identity-presets.js +128 -0
  24. package/spec-cli/src/index.ts +158 -87
  25. package/spec-cli/src/init.ts +53 -25
  26. package/spec-cli/src/layout.ts +6 -5
  27. package/spec-cli/src/lint.ts +25 -121
  28. package/spec-cli/src/login-page.ts +12 -5
  29. package/spec-cli/src/materialize.ts +39 -13
  30. package/spec-cli/src/mentions.ts +16 -11
  31. package/spec-cli/src/message-stream.ts +147 -0
  32. package/spec-cli/src/opencode-headless.ts +95 -0
  33. package/spec-cli/src/opencode.ts +145 -0
  34. package/spec-cli/src/pi-harness.ts +124 -0
  35. package/spec-cli/src/pi-headless.ts +195 -0
  36. package/spec-cli/src/project-identity.ts +73 -0
  37. package/spec-cli/src/pty-bridge.ts +321 -521
  38. package/spec-cli/src/pty-helper.mjs +86 -0
  39. package/spec-cli/src/reaper.ts +16 -8
  40. package/spec-cli/src/reviewSnapshot.ts +23 -0
  41. package/spec-cli/src/reviews.ts +347 -0
  42. package/spec-cli/src/session-timeline.ts +14 -0
  43. package/spec-cli/src/sessions.ts +211 -75
  44. package/spec-cli/src/shim-runtime.ts +169 -0
  45. package/spec-cli/src/slash-commands.ts +76 -0
  46. package/spec-cli/src/source-files.ts +79 -0
  47. package/spec-cli/src/specs.ts +12 -7
  48. package/spec-cli/src/supervise.ts +37 -15
  49. package/spec-cli/src/tree.ts +8 -8
  50. package/spec-cli/src/uninstall.ts +56 -23
  51. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  52. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  53. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  54. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  55. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  56. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  58. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  59. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  60. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  62. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  63. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  64. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  65. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  66. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  67. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  68. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  69. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  70. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  71. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  72. package/spec-cli/templates/spec/project/spec.md +6 -5
  73. package/spec-cli/templates/spexcode.json +10 -2
  74. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  75. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
  76. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  77. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  78. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  79. package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
  80. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  81. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  82. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  83. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
  84. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
  85. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  86. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  87. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  88. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  89. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  90. package/spec-dashboard/dist/index.html +10 -10
  91. package/spec-dashboard/src/reviewFilters.js +279 -0
  92. package/spec-dashboard/src/reviewQuery.js +174 -0
  93. package/spec-dashboard/src/session.js +167 -0
  94. package/spec-eval/src/cli.ts +49 -28
  95. package/spec-eval/src/humanok.ts +1 -1
  96. package/spec-eval/src/matrix.ts +693 -0
  97. package/spec-eval/src/sessioneval.ts +726 -66
  98. package/spec-cli/src/migrate-table.ts +0 -408
  99. package/spec-cli/src/migrate.ts +0 -386
  100. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  108. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  109. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  110. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  111. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
@@ -0,0 +1,195 @@
1
+ import { createConnection, createServer, type Server, type Socket } from 'node:net'
2
+ import { spawn, type ChildProcess } from 'node:child_process'
3
+ import { mkdirSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
8
+
9
+ type ControlRequest = { type: 'deliver'; text: string }
10
+ type ChildTurn = { process: ChildProcess; exited: Promise<number | null> }
11
+
12
+ const PKG = fileURLToPath(new URL('..', import.meta.url))
13
+ const SPEX = join(PKG, 'bin', 'spex.mjs')
14
+ const CONTROL_TIMEOUT_MS = 30_000
15
+ const START_TIMEOUT_MS = 30_000
16
+
17
+ const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
18
+
19
+ /** The resident controller socket is distinct from pi's per-turn rendezvous socket. */
20
+ export const piHeadlessSock = (id: string) => join(tmpdir(), `spexcode-ph-${id}.sock`)
21
+
22
+ export function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: string): string {
23
+ return [shQuote(SPEX), 'internal', 'pi-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(piCmd), '--'].join(' ')
24
+ }
25
+
26
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
27
+ return new Promise((resolve, reject) => {
28
+ const timer = setTimeout(() => reject(new Error(message)), ms)
29
+ promise.then(
30
+ (value) => { clearTimeout(timer); resolve(value) },
31
+ (error) => { clearTimeout(timer); reject(error) },
32
+ )
33
+ })
34
+ }
35
+
36
+ function controlRequest(id: string, request: ControlRequest): Promise<DispatchResult> {
37
+ return new Promise((resolve) => {
38
+ let socket: Socket | undefined
39
+ let settled = false
40
+ let buffer = ''
41
+ const finish = (result: DispatchResult) => {
42
+ if (settled) return
43
+ settled = true
44
+ clearTimeout(timer)
45
+ socket?.destroy()
46
+ resolve(result)
47
+ }
48
+ const timer = setTimeout(() => finish({ ok: false, error: `pi-headless control timed out for session ${id}` }), CONTROL_TIMEOUT_MS)
49
+ try { socket = createConnection(piHeadlessSock(id)) } catch (error) {
50
+ finish({ ok: false, error: `pi-headless controller connect failed for session ${id}: ${(error as Error).message}` })
51
+ return
52
+ }
53
+ socket.setEncoding('utf8')
54
+ socket.on('connect', () => socket!.write(`${JSON.stringify(request)}\n`))
55
+ socket.on('data', (chunk) => {
56
+ buffer += chunk
57
+ const nl = buffer.indexOf('\n')
58
+ if (nl < 0) return
59
+ try {
60
+ const response = JSON.parse(buffer.slice(0, nl)) as DispatchResult
61
+ finish(response.ok ? { ok: true } : { ok: false, error: response.error || 'pi-headless controller rejected the request' })
62
+ } catch (error) {
63
+ finish({ ok: false, error: `pi-headless returned an invalid control response: ${(error as Error).message}` })
64
+ }
65
+ })
66
+ socket.on('error', (error) => finish({ ok: false, error: `pi-headless controller unreachable for session ${id}: ${error.message}` }))
67
+ socket.on('close', () => finish({ ok: false, error: `pi-headless controller closed before confirming session ${id}` }))
68
+ })
69
+ }
70
+
71
+ export const deliverViaPiHeadless = (rec: HarnessDeliveryRecord, text: string) =>
72
+ controlRequest(rec.session, { type: 'deliver', text })
73
+
74
+ export class PiHeadlessController {
75
+ private server: Server | null = null
76
+ private child: ChildTurn | null = null
77
+ private controlQueue: Promise<void> = Promise.resolve()
78
+ private closing = false
79
+ private readonly socketPath: string
80
+
81
+ constructor(
82
+ private readonly id: string,
83
+ _runtimeDir: string,
84
+ private readonly piCmd: string,
85
+ private readonly cwd = process.cwd(),
86
+ ) {
87
+ this.socketPath = piHeadlessSock(id)
88
+ }
89
+
90
+ async start(initialPrompt?: string): Promise<void> {
91
+ try { rmSync(this.socketPath, { force: true }) } catch { /* stale control socket is replaced at startup */ }
92
+ this.server = createServer((socket) => this.accept(socket))
93
+ await new Promise<void>((resolve, reject) => {
94
+ const onError = (error: Error) => { this.server?.off('listening', onListening); reject(error) }
95
+ const onListening = () => { this.server?.off('error', onError); resolve() }
96
+ this.server!.once('error', onError)
97
+ this.server!.once('listening', onListening)
98
+ this.server!.listen(this.socketPath)
99
+ })
100
+ if (initialPrompt) void this.spawnTurn(initialPrompt, false).catch((error) => {
101
+ console.error(`[spex pi-headless] initial turn failed: ${(error as Error).message}`)
102
+ })
103
+ }
104
+
105
+ async close(): Promise<void> {
106
+ if (this.closing) return
107
+ this.closing = true
108
+ const child = this.child
109
+ if (child && child.process.exitCode === null) child.process.kill('SIGTERM')
110
+ await new Promise<void>((resolve) => {
111
+ if (!this.server) return resolve()
112
+ this.server.close(() => resolve())
113
+ })
114
+ try { rmSync(this.socketPath, { force: true }) } catch { /* best-effort cleanup after close */ }
115
+ const { rvSock } = await import('./harness.js')
116
+ try { rmSync(rvSock(this.id), { force: true }) } catch { /* pi may already have removed it */ }
117
+ }
118
+
119
+ private accept(socket: Socket): void {
120
+ socket.setEncoding('utf8')
121
+ let buffer = ''
122
+ let handled = false
123
+ socket.on('data', (chunk) => {
124
+ if (handled) return
125
+ buffer += chunk
126
+ const nl = buffer.indexOf('\n')
127
+ if (nl < 0) return
128
+ handled = true
129
+ let request: ControlRequest
130
+ try { request = JSON.parse(buffer.slice(0, nl)) as ControlRequest } catch (error) {
131
+ socket.end(`${JSON.stringify({ ok: false, error: `invalid control request: ${(error as Error).message}` })}\n`)
132
+ return
133
+ }
134
+ this.controlQueue = this.controlQueue.then(async () => {
135
+ const result = await this.handle(request).catch((error) => ({ ok: false, error: (error as Error).message }))
136
+ socket.end(`${JSON.stringify(result)}\n`)
137
+ })
138
+ })
139
+ }
140
+
141
+ private async handle(request: ControlRequest): Promise<DispatchResult> {
142
+ if (request.type !== 'deliver') return { ok: false, error: 'unknown pi-headless control request' }
143
+ if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
144
+
145
+ // A live extension listener is an in-flight pi turn. The native steer path is parse-confirmed by the
146
+ // shared rendezvous protocol. Only a proven absent listener may cold-wake a saved session.
147
+ const { deliverViaRendezvous, rendezvousListening } = await import('./harness.js')
148
+ const listener = await rendezvousListening(this.id)
149
+ if (listener === 'live') return deliverViaRendezvous(this.id, request.text)
150
+ if (listener === 'unproven') return { ok: false, error: `could not determine whether pi turn ${this.id} is live — prompt NOT delivered` }
151
+
152
+ if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
153
+ await this.spawnTurn(request.text, true)
154
+ return { ok: true }
155
+ }
156
+
157
+ private async spawnTurn(text: string, resume: boolean): Promise<void> {
158
+ if (this.closing) throw new Error('pi-headless controller is closing')
159
+ const mode = resume ? ['--session', this.id] : ['--session-id', this.id]
160
+ // Keep pi's default text mode. `--mode json` is intentionally omitted: it can hang in this runtime.
161
+ const args = ['-p', ...mode, text]
162
+ const command = `exec ${this.piCmd} ${args.map(shQuote).join(' ')}`
163
+ const childProcess = spawn('/bin/sh', ['-lc', command], { cwd: this.cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] })
164
+ let resolveExit!: (code: number | null) => void
165
+ const exited = new Promise<number | null>((resolve) => { resolveExit = resolve })
166
+ const turn: ChildTurn = { process: childProcess, exited }
167
+ this.child = turn
168
+ childProcess.stdout?.pipe(process.stdout)
169
+ childProcess.stderr?.pipe(process.stderr)
170
+ childProcess.once('error', (error) => console.error(`[spex pi-headless] child spawn failed: ${error.message}`))
171
+ childProcess.once('close', (code) => {
172
+ if (this.child === turn) this.child = null
173
+ resolveExit(code)
174
+ })
175
+ await withTimeout(new Promise<void>((resolve, reject) => {
176
+ childProcess.once('spawn', () => resolve())
177
+ childProcess.once('error', reject)
178
+ }), START_TIMEOUT_MS, `pi-headless child did not start for session ${this.id}`)
179
+ }
180
+ }
181
+
182
+ export async function runPiHeadlessController(id: string, runtimeDir: string, piCmd: string, tail: string[]): Promise<void> {
183
+ // runtimeDir is retained in the command shape for parity with claude-headless and future per-session output.
184
+ mkdirSync(join(runtimeDir, 'sessions', id), { recursive: true })
185
+ const controller = new PiHeadlessController(id, runtimeDir, piCmd)
186
+ const resume = tail[0] === '--session'
187
+ const prompt = resume ? undefined : tail[0] === '--session-id' ? tail.slice(2).join(' ') : tail.join(' ')
188
+ await controller.start(prompt)
189
+ await new Promise<void>((resolve) => {
190
+ const stop = () => void controller.close().finally(resolve)
191
+ process.once('SIGINT', stop)
192
+ process.once('SIGTERM', stop)
193
+ process.once('SIGHUP', stop)
194
+ })
195
+ }
@@ -0,0 +1,73 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { basename, dirname, join } from 'node:path'
4
+ import { spexcodeHome } from './layout.js'
5
+ import {
6
+ resolvedIdentityIcon, DEFAULT_GATEWAY_ICON, DEFAULT_PROJECT_ICON, requireIdentityChoice,
7
+ } from './identity-presets.js'
8
+
9
+ export type ResolvedIdentity = { title: string; icon: string }
10
+ export type GatewayIdentitySource = { identity: ResolvedIdentity; revision: string }
11
+
12
+ const revisionOf = (raw: string | null): string =>
13
+ createHash('sha256').update(raw === null ? 'missing' : `present\0${raw}`).digest('hex')
14
+
15
+ function readObject(file: string): { value: Record<string, any>; raw: string | null } {
16
+ if (!existsSync(file)) return { value: {}, raw: null }
17
+ const raw = readFileSync(file, 'utf8')
18
+ let value: unknown
19
+ try { value = JSON.parse(raw) }
20
+ catch (e) { throw new Error(`malformed ${file}: ${(e as Error).message}`) }
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${file} must contain one top-level JSON object`)
22
+ return { value: value as Record<string, any>, raw }
23
+ }
24
+
25
+ export function resolveProjectIdentity(configRoot: string, canonicalRoot = configRoot): ResolvedIdentity {
26
+ const { value } = readObject(join(configRoot, 'spexcode.json'))
27
+ const dashboard = value.dashboard && typeof value.dashboard === 'object' && !Array.isArray(value.dashboard)
28
+ ? value.dashboard as Record<string, unknown>
29
+ : {}
30
+ const configuredTitle = typeof dashboard.title === 'string' ? dashboard.title.trim() : ''
31
+ return {
32
+ title: configuredTitle || basename(canonicalRoot),
33
+ icon: resolvedIdentityIcon(dashboard.icon, DEFAULT_PROJECT_ICON),
34
+ }
35
+ }
36
+
37
+ export const hostConfigPath = (): string => join(spexcodeHome(), 'config.json')
38
+
39
+ export function readGatewayIdentity(): GatewayIdentitySource {
40
+ const { value, raw } = readObject(hostConfigPath())
41
+ const gateway = value.gateway && typeof value.gateway === 'object' && !Array.isArray(value.gateway)
42
+ ? value.gateway as Record<string, unknown>
43
+ : {}
44
+ return {
45
+ identity: { title: 'Projects', icon: resolvedIdentityIcon(gateway.icon, DEFAULT_GATEWAY_ICON) },
46
+ revision: revisionOf(raw),
47
+ }
48
+ }
49
+
50
+ export function writeGatewayIcon(icon: unknown, revision: string): GatewayIdentitySource {
51
+ const canonical = requireIdentityChoice(icon)
52
+ const file = hostConfigPath()
53
+ const current = readObject(file)
54
+ if (revision !== revisionOf(current.raw)) {
55
+ const error = new Error('host config changed on disk — reload before saving') as Error & { status?: number }
56
+ error.status = 409
57
+ throw error
58
+ }
59
+ const gateway = current.value.gateway && typeof current.value.gateway === 'object' && !Array.isArray(current.value.gateway)
60
+ ? current.value.gateway as Record<string, unknown>
61
+ : {}
62
+ const next = { ...current.value, gateway: { ...gateway, icon: canonical } }
63
+ const raw = `${JSON.stringify(next, null, 2)}\n`
64
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
65
+ const tmp = `${file}.${process.pid}.tmp`
66
+ try {
67
+ writeFileSync(tmp, raw, { mode: 0o600 })
68
+ renameSync(tmp, file)
69
+ } finally {
70
+ try { rmSync(tmp) } catch { /* rename consumed it / write never created it */ }
71
+ }
72
+ return { identity: { title: 'Projects', icon: canonical }, revision: revisionOf(raw) }
73
+ }