spexcode 0.4.1 → 0.4.3
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/cli.ts +88 -37
- package/spec-cli/src/client.ts +34 -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 +105 -55
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +181 -33
- package/spec-cli/src/help.ts +64 -27
- 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 +145 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -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/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -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 +188 -66
- 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 +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.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,124 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { shimRuntimeSource } from './shim-runtime.js'
|
|
5
|
+
|
|
6
|
+
// @@@ pi-harness helpers - the pi-ONLY machinery behind harness.ts's piHarness adapter: the generated
|
|
7
|
+
// extension source (pi's shim is a TypeScript extension, not a hooks JSON — pi has no external hook binding;
|
|
8
|
+
// its lifecycle surface IS the extension API) and the trust writer (pi's project-trust store,
|
|
9
|
+
// ~/.pi/agent/trust.json). Kept out of harness.ts so the adapter table stays scannable; imports NOTHING from
|
|
10
|
+
// harness.ts at runtime (no cycle — harness.ts imports us).
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
13
|
+
// the pi shim = a generated extension at .pi/extensions/spexcode.ts (pi runs .ts extensions natively). It is
|
|
14
|
+
// a THIN HOST over the shared shim runtime ([[shim-runtime]], embedded verbatim): this generator declares
|
|
15
|
+
// only pi's event-name mapping and its ExtensionAPI bindings —
|
|
16
|
+
// session_start → SessionStart · input → UserPromptSubmit · tool_call → PreToolUse (blockable) ·
|
|
17
|
+
// tool_result → PostToolUse · agent_end + agent_settled → Stop (dual binding, one shared bit)
|
|
18
|
+
// — while the payload synthesis, the block-verdict parse (exit 2 + stdout decision:block JSON), and the
|
|
19
|
+
// rendezvous server all come from the runtime. pi's two host-specific verdict consumers: a tool_call block
|
|
20
|
+
// returns pi's typed { block: true, reason }; a Stop block has no blocking return, so Stop rides the
|
|
21
|
+
// runtime's dispatchStop on a DUAL binding — agent_end (awaited in the run loop: a queued teach drains as
|
|
22
|
+
// the same prompt's continuation, never an orphan) plus agent_settled (fires once per prompt after every
|
|
23
|
+
// drain: the terminal backstop whose flagged dispatch guarantees the gate's escape runs before settlement
|
|
24
|
+
// completes) — with stop_hook_active synthesized across both and the gate's reason SENT BACK IN as a user
|
|
25
|
+
// message; a no-longer-injectable host is reported loud — never silently dropped (a dropped rejection is
|
|
26
|
+
// a session stuck `active` forever).
|
|
27
|
+
// The rendezvous inject is pi.sendUserMessage({deliverAs: steer}), always able → no canInject gate.
|
|
28
|
+
export function piExtensionSource(dispatch: string, spex: string): string {
|
|
29
|
+
return `// generated by spexcode materialize — DO NOT EDIT (regenerated per clone; identity: dispatch.sh)
|
|
30
|
+
// SpexCode's pi shim: forwards pi lifecycle events to dispatch.sh as Claude-shaped hook payloads, and serves
|
|
31
|
+
// this session's rendezvous socket for prompt delivery + liveness.
|
|
32
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
33
|
+
${shimRuntimeSource('pi', dispatch, spex)}
|
|
34
|
+
|
|
35
|
+
// pi tool names → the Claude tool vocabulary harness.sh parses (unknown names pass through untouched).
|
|
36
|
+
const TOOL: Record<string, string> = { read: "Read", edit: "Edit", write: "Write", bash: "Bash" }
|
|
37
|
+
|
|
38
|
+
export default function spexcode(pi: ExtensionAPI) {
|
|
39
|
+
let sessionId = process.env.SPEXCODE_SESSION_ID || ""
|
|
40
|
+
const rt = spexShimRuntime({ sessionId: () => sessionId })
|
|
41
|
+
const toolFields = (toolName: string, input: unknown) =>
|
|
42
|
+
({ tool_name: TOOL[toolName] ?? toolName, tool_input: rt.toolInput(input, "path") })
|
|
43
|
+
|
|
44
|
+
// bound as soon as the extension loads so liveness reads online early; only a governed launch carries the
|
|
45
|
+
// socket env (sessions.ts rvEnv) — a self-launched bare \`pi\` skips this.
|
|
46
|
+
const rv = rt.serveRendezvous((text: string) => pi.sendUserMessage(text, { deliverAs: "steer" }))
|
|
47
|
+
|
|
48
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
49
|
+
sessionId = ctx.sessionManager.getSessionId() || sessionId
|
|
50
|
+
process.env.PI_SESSION_ID = sessionId // tool subprocesses inherit it → spex CLI knows its session
|
|
51
|
+
await rt.dispatchEvent("SessionStart")
|
|
52
|
+
})
|
|
53
|
+
pi.on("input", async (event) => { await rt.dispatchEvent("UserPromptSubmit", { prompt: event.text }) })
|
|
54
|
+
pi.on("tool_call", async (event) => {
|
|
55
|
+
const r = await rt.dispatchEvent("PreToolUse", toolFields(event.toolName, event.input))
|
|
56
|
+
if (rt.blocked(r)) return { block: true, reason: rt.blockReason(r, "blocked by a SpexCode hook") }
|
|
57
|
+
})
|
|
58
|
+
pi.on("tool_result", async (event) => { await rt.dispatchEvent("PostToolUse", toolFields(event.toolName, event.input)) })
|
|
59
|
+
// Stop rides the runtime's dispatchStop on TWO bindings that never duplicate: agent_end performs the
|
|
60
|
+
// NORMAL dispatch (pi awaits its listeners inside the run loop and drains a queued teach as the SAME
|
|
61
|
+
// awaited prompt's continuation — never the orphaned settle-time prompt whose late inject threw
|
|
62
|
+
// "extension ctx is stale"), while agent_settled — which fires exactly once per prompt, after every
|
|
63
|
+
// drain — consumes AT MOST ONE PENDING BLOCKED stop (measured gap: in real dispatched runs the drained
|
|
64
|
+
// continuation's own agent_end does not reliably re-reach the extension, so without the backstop the
|
|
65
|
+
// flagged escape never runs). A naturally allowed agent_end leaves no
|
|
66
|
+
// pending state and settle dispatches NOTHING (exactly one gate entry per stop); only a blocked
|
|
67
|
+
// agent_end arms it, and the flagged settle dispatch always ends allowed (the gate's continuation paths
|
|
68
|
+
// terminate), clearing the pending bit through a subprocess write with no duplicate inject. An
|
|
69
|
+
// uninjectable host stays caught-loud.
|
|
70
|
+
const stop = () => rt.dispatchStop(
|
|
71
|
+
async (reason: string) => pi.sendUserMessage(reason, { deliverAs: "steer" }),
|
|
72
|
+
"a SpexCode Stop hook blocked this stop without giving a reason",
|
|
73
|
+
)
|
|
74
|
+
pi.on("agent_end", async () => { await stop() })
|
|
75
|
+
pi.on("agent_settled", async () => { if (rt.stopPending()) await stop() })
|
|
76
|
+
pi.on("session_shutdown", async () => { rv?.close() })
|
|
77
|
+
}
|
|
78
|
+
`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
82
|
+
// pi trust — make a dispatched/self-launched pi load our project-local extension with zero prompts. pi gates
|
|
83
|
+
// EVERY project-local resource (.pi/extensions, .pi/skills, .pi/prompts, .pi/settings.json) behind project
|
|
84
|
+
// trust: saved decisions live in ~/.pi/agent/trust.json as a flat { "<canonical dir>": true|false } map, and
|
|
85
|
+
// the CLOSEST saved decision on the cwd's parent chain wins (pi trust-manager findNearestTrustEntry). So ONE
|
|
86
|
+
// entry keyed by the MAIN CHECKOUT covers every .worktrees/<id> worktree beneath it. The launch additionally
|
|
87
|
+
// carries `--approve` (one-run trust) as defence for a worktree living OUTSIDE the checkout. Writes are
|
|
88
|
+
// idempotent and surgical: other projects' decisions are untouched; a corrupt trust.json FAILS LOUD rather
|
|
89
|
+
// than being clobbered; remove only deletes a `true` we could have written, never a user's saved "false".
|
|
90
|
+
|
|
91
|
+
// pi has no config-dir env of its own (hardcoded ~/.pi/agent) — SPEXCODE_PI_AGENT_DIR is OUR test seam only.
|
|
92
|
+
const piAgentDir = () => process.env.SPEXCODE_PI_AGENT_DIR || join(homedir(), '.pi', 'agent')
|
|
93
|
+
// pi canonicalizes trust keys (realpath); mirror it so our key matches its lookup.
|
|
94
|
+
const canonical = (p: string) => { try { return realpathSync(p) } catch { return p } }
|
|
95
|
+
|
|
96
|
+
function readTrust(file: string): Record<string, unknown> {
|
|
97
|
+
if (!existsSync(file)) return {}
|
|
98
|
+
const raw = readFileSync(file, 'utf8')
|
|
99
|
+
try { return JSON.parse(raw) as Record<string, unknown> } catch (e) {
|
|
100
|
+
throw new Error(`${file} is not valid JSON — refusing to rewrite pi's trust store: ${String(e)}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function writePiTrust(proj: string): string {
|
|
105
|
+
const dir = piAgentDir()
|
|
106
|
+
const file = join(dir, 'trust.json')
|
|
107
|
+
const key = canonical(proj)
|
|
108
|
+
const data = readTrust(file)
|
|
109
|
+
if (data[key] === true) return file // already trusted — no churn
|
|
110
|
+
data[key] = true
|
|
111
|
+
mkdirSync(dir, { recursive: true })
|
|
112
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + '\n')
|
|
113
|
+
return file
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function removePiTrust(proj: string): void {
|
|
117
|
+
const file = join(piAgentDir(), 'trust.json')
|
|
118
|
+
if (!existsSync(file)) return
|
|
119
|
+
const key = canonical(proj)
|
|
120
|
+
const data = readTrust(file)
|
|
121
|
+
if (data[key] !== true) return // absent, or a user's own "do not trust" — never touch it
|
|
122
|
+
delete data[key]
|
|
123
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + '\n')
|
|
124
|
+
}
|
|
@@ -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
|
+
}
|