@spexcode/spec-cli 0.6.5
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 +95 -0
- package/bin/spex.mjs +76 -0
- package/dist/attach.d.ts +2 -0
- package/dist/attach.js +48 -0
- package/dist/claude-headless.d.ts +33 -0
- package/dist/claude-headless.js +307 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1859 -0
- package/dist/client.d.ts +111 -0
- package/dist/client.js +476 -0
- package/dist/cockpit.d.ts +7 -0
- package/dist/cockpit.js +26 -0
- package/dist/codex-headless.d.ts +1 -0
- package/dist/codex-headless.js +7 -0
- package/dist/codex-runtime-generations.d.ts +53 -0
- package/dist/codex-runtime-generations.js +686 -0
- package/dist/commit-surgery.d.ts +1 -0
- package/dist/commit-surgery.js +90 -0
- package/dist/contract-filter.d.ts +14 -0
- package/dist/contract-filter.js +219 -0
- package/dist/dashboard-assets.d.ts +6 -0
- package/dist/dashboard-assets.js +50 -0
- package/dist/delivery-queue.d.ts +23 -0
- package/dist/delivery-queue.js +179 -0
- package/dist/doctor.d.ts +9 -0
- package/dist/doctor.js +547 -0
- package/dist/eval-host.d.ts +1 -0
- package/dist/eval-host.js +19 -0
- package/dist/execution-trace.d.ts +26 -0
- package/dist/execution-trace.js +475 -0
- package/dist/file-write.d.ts +2 -0
- package/dist/file-write.js +26 -0
- package/dist/flat.d.ts +89 -0
- package/dist/flat.js +1076 -0
- package/dist/gateway-auth.d.ts +56 -0
- package/dist/gateway-auth.js +168 -0
- package/dist/gateway-hub.d.ts +28 -0
- package/dist/gateway-hub.js +361 -0
- package/dist/gateway.d.ts +37 -0
- package/dist/gateway.js +503 -0
- package/dist/graphCache.d.ts +27 -0
- package/dist/graphCache.js +716 -0
- package/dist/graphSnapshot.d.ts +14 -0
- package/dist/graphSnapshot.js +35 -0
- package/dist/graphStream.d.ts +85 -0
- package/dist/graphStream.js +1195 -0
- package/dist/guidance-catalog.d.ts +64 -0
- package/dist/guidance-catalog.js +166 -0
- package/dist/guide.d.ts +8 -0
- package/dist/guide.js +684 -0
- package/dist/harness-select.d.ts +18 -0
- package/dist/harness-select.js +62 -0
- package/dist/harness.d.ts +325 -0
- package/dist/harness.js +3026 -0
- package/dist/headless-controller.d.ts +9 -0
- package/dist/headless-controller.js +47 -0
- package/dist/help.d.ts +10 -0
- package/dist/help.js +574 -0
- package/dist/hook-prompts.d.ts +18 -0
- package/dist/hook-prompts.js +122 -0
- package/dist/hooks.d.ts +1 -0
- package/dist/hooks.js +27 -0
- package/dist/host-resources.d.ts +97 -0
- package/dist/host-resources.js +795 -0
- package/dist/host.d.ts +88 -0
- package/dist/host.js +663 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +899 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +284 -0
- package/dist/issues-cli.d.ts +6 -0
- package/dist/issues-cli.js +334 -0
- package/dist/issues.d.ts +69 -0
- package/dist/issues.js +189 -0
- package/dist/lint.d.ts +34 -0
- package/dist/lint.js +476 -0
- package/dist/listen.d.ts +7 -0
- package/dist/listen.js +24 -0
- package/dist/localIssues.d.ts +65 -0
- package/dist/localIssues.js +582 -0
- package/dist/login-page.d.ts +5 -0
- package/dist/login-page.js +86 -0
- package/dist/loop-in.d.ts +23 -0
- package/dist/loop-in.js +68 -0
- package/dist/machine-peer.d.ts +69 -0
- package/dist/machine-peer.js +603 -0
- package/dist/materialize.d.ts +18 -0
- package/dist/materialize.js +468 -0
- package/dist/mentions.d.ts +50 -0
- package/dist/mentions.js +117 -0
- package/dist/opencode-headless.d.ts +4 -0
- package/dist/opencode-headless.js +208 -0
- package/dist/opencode.d.ts +3 -0
- package/dist/opencode.js +142 -0
- package/dist/pi-harness.d.ts +3 -0
- package/dist/pi-harness.js +129 -0
- package/dist/pi-headless.d.ts +25 -0
- package/dist/pi-headless.js +196 -0
- package/dist/plugin-harness.d.ts +21 -0
- package/dist/plugin-harness.js +146 -0
- package/dist/pty-bridge.d.ts +10 -0
- package/dist/pty-bridge.js +404 -0
- package/dist/pty-helper.d.mts +1 -0
- package/dist/pty-helper.mjs +101 -0
- package/dist/pty-native-helper.d.mts +2 -0
- package/dist/pty-native-helper.mjs +23 -0
- package/dist/public-graph.d.ts +46 -0
- package/dist/public-graph.js +56 -0
- package/dist/ranker.d.ts +15 -0
- package/dist/ranker.js +188 -0
- package/dist/reaper.d.ts +6 -0
- package/dist/reaper.js +81 -0
- package/dist/reviews.d.ts +189 -0
- package/dist/reviews.js +350 -0
- package/dist/runtime-guard.d.ts +6 -0
- package/dist/runtime-guard.js +37 -0
- package/dist/runtime-ownership.d.ts +21 -0
- package/dist/runtime-ownership.js +84 -0
- package/dist/search.bench.d.mts +1 -0
- package/dist/search.bench.mjs +75 -0
- package/dist/search.d.ts +20 -0
- package/dist/search.js +61 -0
- package/dist/session-cursors.d.ts +14 -0
- package/dist/session-cursors.js +82 -0
- package/dist/session-declarations.d.ts +13 -0
- package/dist/session-declarations.js +126 -0
- package/dist/session-execution.d.ts +10 -0
- package/dist/session-execution.js +70 -0
- package/dist/session-files.d.ts +28 -0
- package/dist/session-files.js +130 -0
- package/dist/session-follow.d.ts +31 -0
- package/dist/session-follow.js +179 -0
- package/dist/session-reparent.d.ts +10 -0
- package/dist/session-reparent.js +33 -0
- package/dist/session-timeline.d.ts +51 -0
- package/dist/session-timeline.js +233 -0
- package/dist/session-web.d.ts +24 -0
- package/dist/session-web.js +141 -0
- package/dist/sessions.d.ts +412 -0
- package/dist/sessions.js +4239 -0
- package/dist/sh.d.ts +1 -0
- package/dist/sh.js +3 -0
- package/dist/shim-runtime.d.ts +1 -0
- package/dist/shim-runtime.js +161 -0
- package/dist/slash-commands.d.ts +9 -0
- package/dist/slash-commands.js +307 -0
- package/dist/source-files.d.ts +9 -0
- package/dist/source-files.js +113 -0
- package/dist/supervise.d.ts +1 -0
- package/dist/supervise.js +308 -0
- package/dist/tree.d.ts +25 -0
- package/dist/tree.js +98 -0
- package/dist/tsx-bin.d.ts +3 -0
- package/dist/tsx-bin.js +41 -0
- package/dist/uninstall.d.ts +3 -0
- package/dist/uninstall.js +177 -0
- package/dist/uploads.d.ts +36 -0
- package/dist/uploads.js +243 -0
- package/dist/worktree-sources.d.ts +1 -0
- package/dist/worktree-sources.js +47 -0
- package/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/hooks/dispatch.sh +105 -0
- package/hooks/harness.sh +272 -0
- package/package.json +47 -0
- package/templates/hooks/commit-msg +14 -0
- package/templates/hooks/post-checkout +25 -0
- package/templates/hooks/post-merge +34 -0
- package/templates/hooks/pre-commit +120 -0
- package/templates/hooks/prepare-commit-msg +35 -0
- package/templates/hooks/reference-transaction +105 -0
- package/templates/spec/project/.plugins/commands/extract/spec.md +66 -0
- package/templates/spec/project/.plugins/commands/regroup/spec.md +25 -0
- package/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/templates/spec/project/.plugins/commands/spec.md +30 -0
- package/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- package/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
- package/templates/spec/project/.plugins/core/idle/idle.sh +15 -0
- package/templates/spec/project/.plugins/core/idle/spec.md +13 -0
- package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +59 -0
- package/templates/spec/project/.plugins/core/mark-active/spec.md +22 -0
- package/templates/spec/project/.plugins/core/session-fail/fail.sh +12 -0
- package/templates/spec/project/.plugins/core/session-fail/spec.md +15 -0
- package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +43 -0
- package/templates/spec/project/.plugins/core/spec-first/spec.md +17 -0
- package/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +66 -0
- package/templates/spec/project/.plugins/core/spec-of-file/spec.md +15 -0
- package/templates/spec/project/.plugins/core/spec.md +22 -0
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +23 -0
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +149 -0
- package/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
- package/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
- package/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
- package/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
- package/templates/spec/project/.plugins/prompts/spec.md +24 -0
- package/templates/spec/project/.plugins/skills/distill/digest.mjs +136 -0
- package/templates/spec/project/.plugins/skills/distill/spec.md +67 -0
- package/templates/spec/project/.plugins/skills/spec.md +19 -0
- package/templates/spec/project/.plugins/spec.md +23 -0
- package/templates/spec/project/spec.md +20 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
import { spawn } 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 { controlRequest, withTimeout } from './headless-controller.js';
|
|
8
|
+
import { shQuote } from './sh.js';
|
|
9
|
+
const PKG = fileURLToPath(new URL('..', import.meta.url));
|
|
10
|
+
const SPEX = join(PKG, 'bin', 'spex.mjs');
|
|
11
|
+
const CONTROL_TIMEOUT_MS = 30_000;
|
|
12
|
+
const START_TIMEOUT_MS = 30_000;
|
|
13
|
+
const TERM_EXIT_GRACE_MS = 500;
|
|
14
|
+
const KILL_EXIT_GRACE_MS = 2_000;
|
|
15
|
+
/** The resident controller socket is distinct from pi's per-turn rendezvous socket. */
|
|
16
|
+
export const piHeadlessSock = (id) => join(tmpdir(), `spexcode-ph-${id}.sock`);
|
|
17
|
+
export function piHeadlessLaunchCommand(id, runtimeDir, piCmd) {
|
|
18
|
+
return [shQuote(SPEX), 'internal', 'pi-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(piCmd), '--'].join(' ');
|
|
19
|
+
}
|
|
20
|
+
export const deliverViaPiHeadless = (rec, text) => controlRequest(piHeadlessSock(rec.session), { type: 'deliver', text, mid: rec.mid }, {
|
|
21
|
+
name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
|
|
22
|
+
rejected: 'pi-headless controller rejected the request',
|
|
23
|
+
});
|
|
24
|
+
// The resident controller and a running pi turn own two per-session listeners. The generic lifecycle
|
|
25
|
+
// teardown has already proved and removed the exact controller leaf before this runs; cold filing is valid
|
|
26
|
+
// only once neither listener can still accept work for this session.
|
|
27
|
+
export async function piHeadlessColdRuntime(rec) {
|
|
28
|
+
const { listenerAt, rvSock } = await import('./harness.js');
|
|
29
|
+
const paths = [piHeadlessSock(rec.session), rvSock(rec.session)];
|
|
30
|
+
const probes = await Promise.all(paths.map((path) => listenerAt(path)));
|
|
31
|
+
const pending = paths.filter((_, index) => probes[index] !== 'dead');
|
|
32
|
+
return pending.length
|
|
33
|
+
? { ok: false, error: `pi-headless runtime is still ${probes.some((probe) => probe === 'live') ? 'live' : 'unproven'} (${pending.join(', ')})` }
|
|
34
|
+
: { ok: true };
|
|
35
|
+
}
|
|
36
|
+
export class PiHeadlessController {
|
|
37
|
+
id;
|
|
38
|
+
piCmd;
|
|
39
|
+
cwd;
|
|
40
|
+
server = null;
|
|
41
|
+
child = null;
|
|
42
|
+
controlQueue = Promise.resolve();
|
|
43
|
+
closing = false;
|
|
44
|
+
socketPath;
|
|
45
|
+
constructor(id, _runtimeDir, piCmd, cwd = process.cwd()) {
|
|
46
|
+
this.id = id;
|
|
47
|
+
this.piCmd = piCmd;
|
|
48
|
+
this.cwd = cwd;
|
|
49
|
+
this.socketPath = piHeadlessSock(id);
|
|
50
|
+
}
|
|
51
|
+
async start(initialPrompt) {
|
|
52
|
+
try {
|
|
53
|
+
rmSync(this.socketPath, { force: true });
|
|
54
|
+
}
|
|
55
|
+
catch { /* stale control socket is replaced at startup */ }
|
|
56
|
+
this.server = createServer((socket) => this.accept(socket));
|
|
57
|
+
await new Promise((resolve, reject) => {
|
|
58
|
+
const onError = (error) => { this.server?.off('listening', onListening); reject(error); };
|
|
59
|
+
const onListening = () => { this.server?.off('error', onError); resolve(); };
|
|
60
|
+
this.server.once('error', onError);
|
|
61
|
+
this.server.once('listening', onListening);
|
|
62
|
+
this.server.listen(this.socketPath);
|
|
63
|
+
});
|
|
64
|
+
if (initialPrompt)
|
|
65
|
+
void this.spawnTurn(initialPrompt, false).catch((error) => {
|
|
66
|
+
console.error(`[spex pi-headless] initial turn failed: ${error.message}`);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async close() {
|
|
70
|
+
if (this.closing)
|
|
71
|
+
return;
|
|
72
|
+
this.closing = true;
|
|
73
|
+
const child = this.child;
|
|
74
|
+
try {
|
|
75
|
+
if (child)
|
|
76
|
+
await this.terminateTurn(child);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await new Promise((resolve) => {
|
|
80
|
+
if (!this.server)
|
|
81
|
+
return resolve();
|
|
82
|
+
this.server.close(() => resolve());
|
|
83
|
+
});
|
|
84
|
+
// same proof-before-removal rule as every other teardown (harness.ts unlinkSocks): a socket path is keyed
|
|
85
|
+
// by session id alone, so only a listener PROVEN dead is ours to unlink.
|
|
86
|
+
const { rvSock, unlinkSocks } = await import('./harness.js');
|
|
87
|
+
await unlinkSocks(this.socketPath, rvSock(this.id));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async terminateTurn(turn) {
|
|
91
|
+
if (turn.process.exitCode !== null)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
turn.process.kill('SIGTERM');
|
|
95
|
+
}
|
|
96
|
+
catch { /* the child can leave between the exit check and signal */ }
|
|
97
|
+
if (await this.waitForExit(turn, TERM_EXIT_GRACE_MS))
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
turn.process.kill('SIGKILL');
|
|
101
|
+
}
|
|
102
|
+
catch { /* already gone */ }
|
|
103
|
+
await withTimeout(turn.exited, KILL_EXIT_GRACE_MS, `pi-headless turn did not exit for session ${this.id}`);
|
|
104
|
+
}
|
|
105
|
+
async waitForExit(turn, timeoutMs) {
|
|
106
|
+
try {
|
|
107
|
+
await withTimeout(turn.exited, timeoutMs, 'turn exit grace elapsed');
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
accept(socket) {
|
|
115
|
+
socket.setEncoding('utf8');
|
|
116
|
+
let buffer = '';
|
|
117
|
+
let handled = false;
|
|
118
|
+
socket.on('data', (chunk) => {
|
|
119
|
+
if (handled)
|
|
120
|
+
return;
|
|
121
|
+
buffer += chunk;
|
|
122
|
+
const nl = buffer.indexOf('\n');
|
|
123
|
+
if (nl < 0)
|
|
124
|
+
return;
|
|
125
|
+
handled = true;
|
|
126
|
+
let request;
|
|
127
|
+
try {
|
|
128
|
+
request = JSON.parse(buffer.slice(0, nl));
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
socket.end(`${JSON.stringify({ ok: false, error: `invalid control request: ${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.message }));
|
|
136
|
+
socket.end(`${JSON.stringify(result)}\n`);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async handle(request) {
|
|
141
|
+
if (request.type !== 'deliver')
|
|
142
|
+
return { ok: false, error: 'unknown pi-headless control request' };
|
|
143
|
+
if (!request.text)
|
|
144
|
+
return { ok: false, error: 'empty prompt - nothing to deliver' };
|
|
145
|
+
// A live extension listener is an in-flight pi turn. Only a proven absent listener may cold-wake a saved
|
|
146
|
+
// session; an inconclusive probe must not start a duplicate turn.
|
|
147
|
+
const { deliverViaSocketOrWake } = await import('./harness.js');
|
|
148
|
+
return deliverViaSocketOrWake(this.id, request.text, request.mid, async () => {
|
|
149
|
+
if (this.child)
|
|
150
|
+
await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`);
|
|
151
|
+
await this.spawnTurn(request.text, true);
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}, `could not determine whether pi turn ${this.id} is live — prompt NOT delivered`);
|
|
154
|
+
}
|
|
155
|
+
async spawnTurn(text, resume) {
|
|
156
|
+
if (this.closing)
|
|
157
|
+
throw new Error('pi-headless controller is closing');
|
|
158
|
+
const mode = resume ? ['--session', this.id] : ['--session-id', this.id];
|
|
159
|
+
// Keep pi's default text mode. `--mode json` is intentionally omitted: it can hang in this runtime.
|
|
160
|
+
const args = ['-p', ...mode, text];
|
|
161
|
+
const command = `exec ${this.piCmd} ${args.map(shQuote).join(' ')}`;
|
|
162
|
+
const childProcess = spawn('/bin/sh', ['-lc', command], { cwd: this.cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
163
|
+
let resolveExit;
|
|
164
|
+
const exited = new Promise((resolve) => { resolveExit = resolve; });
|
|
165
|
+
const turn = { process: childProcess, exited };
|
|
166
|
+
this.child = turn;
|
|
167
|
+
childProcess.stdout?.pipe(process.stdout);
|
|
168
|
+
childProcess.stderr?.pipe(process.stderr);
|
|
169
|
+
childProcess.once('error', (error) => console.error(`[spex pi-headless] child spawn failed: ${error.message}`));
|
|
170
|
+
childProcess.once('close', (code) => {
|
|
171
|
+
if (this.child === turn)
|
|
172
|
+
this.child = null;
|
|
173
|
+
resolveExit(code);
|
|
174
|
+
if (code !== 0 && !this.closing)
|
|
175
|
+
void import('./harness.js').then(({ reportHeadlessTurnExit }) => reportHeadlessTurnExit(this.id, 'pi-headless', code, this.cwd));
|
|
176
|
+
});
|
|
177
|
+
await withTimeout(new Promise((resolve, reject) => {
|
|
178
|
+
childProcess.once('spawn', () => resolve());
|
|
179
|
+
childProcess.once('error', reject);
|
|
180
|
+
}), START_TIMEOUT_MS, `pi-headless child did not start for session ${this.id}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export async function runPiHeadlessController(id, runtimeDir, piCmd, tail) {
|
|
184
|
+
// runtimeDir is retained in the command shape for parity with claude-headless and future per-session output.
|
|
185
|
+
mkdirSync(join(runtimeDir, 'sessions', id), { recursive: true });
|
|
186
|
+
const controller = new PiHeadlessController(id, runtimeDir, piCmd);
|
|
187
|
+
const resume = tail[0] === '--session';
|
|
188
|
+
const prompt = resume ? undefined : tail[0] === '--session-id' ? tail.slice(2).join(' ') : tail.join(' ');
|
|
189
|
+
await controller.start(prompt);
|
|
190
|
+
await new Promise((resolve) => {
|
|
191
|
+
const stop = () => void controller.close().finally(resolve);
|
|
192
|
+
process.once('SIGINT', stop);
|
|
193
|
+
process.once('SIGTERM', stop);
|
|
194
|
+
process.once('SIGHUP', stop);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type PluginBundle = {
|
|
2
|
+
contract: string;
|
|
3
|
+
skills: {
|
|
4
|
+
name: string;
|
|
5
|
+
content: string;
|
|
6
|
+
}[];
|
|
7
|
+
agents: {
|
|
8
|
+
name: string;
|
|
9
|
+
content: string;
|
|
10
|
+
}[];
|
|
11
|
+
commands: {
|
|
12
|
+
name: string;
|
|
13
|
+
content: string;
|
|
14
|
+
}[];
|
|
15
|
+
spex: string;
|
|
16
|
+
version: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function pluginBundleDir(proj: string, folder: string): string;
|
|
19
|
+
export declare function emitPlugin(proj: string, folder: string, r: PluginBundle): void;
|
|
20
|
+
export declare function cleanPlugin(proj: string, folder: string): void;
|
|
21
|
+
export declare function pluginVersion(): string;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { copyFileIfChanged, writeFileIfChanged } from './file-write.js';
|
|
5
|
+
// @@@ plugin-harness - the PLUGIN BUNDLE emitter: materialize the whole SpexCode system into ONE self-contained
|
|
6
|
+
// Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.adopter-a` /
|
|
7
|
+
// `.claude` → <folder>/plugins/spexcode/). It is the plugin-target counterpart of the native per-harness
|
|
8
|
+
// write [[harness-adapter]] does — chosen INSTEAD of the natives (plugin exclusivity), so [[harness-delivery]]'s
|
|
9
|
+
// materialize prunes every native first, then emits this. The bundle follows the de-facto Claude-plugin schema
|
|
10
|
+
// (a `.claude-plugin/plugin.json` pointing at hooks/skills/commands/agents); the host's discovery order is
|
|
11
|
+
// `.adopter-a-plugin > .claude-plugin > .codex-plugin` and adopter-a/Claude both read a `.claude-plugin` directly, so
|
|
12
|
+
// the ONE `.claude-plugin` bundle reaches AdopterA, Claude, and (future) Codex from a single emit.
|
|
13
|
+
//
|
|
14
|
+
// The contract is NOT delivered by an always-on CLAUDE.md block here (the bundle never touches the repo's own
|
|
15
|
+
// files) — it maps to a SessionStart hook that emits hookSpecificOutput.additionalContext (the harness-neutral
|
|
16
|
+
// injection Claude/adopter-a normalize, the superpowers pattern), so a plugin host gets the contract with no
|
|
17
|
+
// --append-system-prompt. The hooks reuse the SAME dispatch.sh wiring as the natives, located via the host's
|
|
18
|
+
// ${CLAUDE_PLUGIN_ROOT} variable; dispatch.sh's first arg is the harness id `plugin`, so its shell mirror
|
|
19
|
+
// (harness.sh) parses payloads as the claude family (adopter-a/Claude share Claude's tool names + file_path).
|
|
20
|
+
const PKG = fileURLToPath(new URL('..', import.meta.url)); // installed spec-cli root
|
|
21
|
+
const HOOKS_SRC = join(PKG, 'hooks'); // the canonical dispatch.sh + harness.sh source
|
|
22
|
+
const PLUGIN_NAME = 'spexcode';
|
|
23
|
+
// the host substitutes ${CLAUDE_PLUGIN_ROOT} with the bundle's own absolute path before running a hook command
|
|
24
|
+
// (the same variable adopter-a's hook-compat honours), so dispatch.sh/inject-contract.sh resolve regardless of
|
|
25
|
+
// where the host scanned the bundle from.
|
|
26
|
+
const PLUGIN_ROOT = '${CLAUDE_PLUGIN_ROOT}';
|
|
27
|
+
// the lifecycle events the bundle binds — the Claude/adopter-a superset; a host that fires fewer (Codex) simply
|
|
28
|
+
// never invokes the extras, so binding all is harmless and one emit serves every host.
|
|
29
|
+
const PLUGIN_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'];
|
|
30
|
+
// the bundle dir for a plugin folder: <proj>/<folder>/plugins/spexcode — `<folder>` is the host's plugins-scan
|
|
31
|
+
// root ([[harness-select]] requires it explicit), `plugins/<name>` the conventional bundle location under it.
|
|
32
|
+
export function pluginBundleDir(proj, folder) {
|
|
33
|
+
return join(proj, folder, 'plugins', PLUGIN_NAME);
|
|
34
|
+
}
|
|
35
|
+
// the de-facto Claude-plugin manifest: name (the bundle identity clean() gates on), version, description, and
|
|
36
|
+
// the component pointers Claude/adopter-a discover (hooks.json + the skills/commands/agents dirs).
|
|
37
|
+
function pluginManifest(version) {
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
name: PLUGIN_NAME,
|
|
40
|
+
version,
|
|
41
|
+
description: 'SpexCode — spec-driven dev-flow contract, hooks, skills, commands & agents as one self-contained plugin.',
|
|
42
|
+
hooks: './hooks/hooks.json',
|
|
43
|
+
commands: './commands',
|
|
44
|
+
agents: './agents',
|
|
45
|
+
skills: './skills',
|
|
46
|
+
}, null, 2);
|
|
47
|
+
}
|
|
48
|
+
// hooks.json in the Claude/adopter-a-compatible shape { "hooks": { "<Event>": [{ "hooks": [command…] }] } }. Every
|
|
49
|
+
// event → the SHARED dispatch.sh (`plugin` baked as its harness id, SPEX inherited by handlers); SessionStart
|
|
50
|
+
// ALSO runs inject-contract.sh first, so the contract additionalContext lands alongside the normal dispatch.
|
|
51
|
+
function pluginHooksJson(spex) {
|
|
52
|
+
const dispatch = (e) => `SPEX='${spex}' bash "${PLUGIN_ROOT}/hooks/dispatch.sh" plugin ${e}`;
|
|
53
|
+
const inject = `bash "${PLUGIN_ROOT}/hooks/inject-contract.sh"`;
|
|
54
|
+
const hooks = {};
|
|
55
|
+
for (const e of PLUGIN_EVENTS) {
|
|
56
|
+
const cmds = e === 'SessionStart'
|
|
57
|
+
? [{ type: 'command', command: inject }, { type: 'command', command: dispatch(e) }]
|
|
58
|
+
: [{ type: 'command', command: dispatch(e) }];
|
|
59
|
+
hooks[e] = [{ hooks: cmds }];
|
|
60
|
+
}
|
|
61
|
+
return JSON.stringify({ hooks }, null, 2);
|
|
62
|
+
}
|
|
63
|
+
// the SessionStart hook OUTPUT carrying the contract as additionalContext. JSON-encoded HERE, at materialize time,
|
|
64
|
+
// so the runtime hook is a trivial `cat` — never a fragile shell escaping of arbitrary contract prose.
|
|
65
|
+
function contractContextJson(contract) {
|
|
66
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: contract } }, null, 2);
|
|
67
|
+
}
|
|
68
|
+
// the SessionStart contract injector: print the pre-encoded additionalContext payload (the harness-neutral
|
|
69
|
+
// stand-in for --append-system-prompt). A bare cat — all the encoding happened at materialize time.
|
|
70
|
+
const INJECT_SH = `#!/usr/bin/env bash
|
|
71
|
+
# Emit the SpexCode contract as SessionStart additionalContext — the harness-neutral contract injection (the
|
|
72
|
+
# superpowers pattern; Claude/adopter-a normalize hookSpecificOutput.additionalContext) that replaces a plugin
|
|
73
|
+
# host's missing --append-system-prompt. The JSON was written at materialize time, so this is a trivial cat.
|
|
74
|
+
here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
75
|
+
[ -f "$here/contract-context.json" ] && cat "$here/contract-context.json"
|
|
76
|
+
exit 0
|
|
77
|
+
`;
|
|
78
|
+
// emit (or idempotently re-emit) the whole bundle into <folder>/plugins/spexcode.
|
|
79
|
+
export function emitPlugin(proj, folder, r) {
|
|
80
|
+
const bundle = pluginBundleDir(proj, folder);
|
|
81
|
+
const meta = join(bundle, '.claude-plugin');
|
|
82
|
+
const hooksDir = join(bundle, 'hooks');
|
|
83
|
+
mkdirSync(meta, { recursive: true });
|
|
84
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
85
|
+
writeFileIfChanged(join(meta, 'plugin.json'), pluginManifest(r.version));
|
|
86
|
+
// hooks: the SHARED dispatcher + its shell mirror (copied verbatim — the exact native wiring), the contract
|
|
87
|
+
// injector + its pre-encoded payload, and the event→dispatch binding.
|
|
88
|
+
copyFileIfChanged(join(HOOKS_SRC, 'dispatch.sh'), join(hooksDir, 'dispatch.sh'));
|
|
89
|
+
copyFileIfChanged(join(HOOKS_SRC, 'harness.sh'), join(hooksDir, 'harness.sh'));
|
|
90
|
+
writeFileIfChanged(join(hooksDir, 'inject-contract.sh'), INJECT_SH);
|
|
91
|
+
writeFileIfChanged(join(hooksDir, 'contract-context.json'), contractContextJson(r.contract));
|
|
92
|
+
writeFileIfChanged(join(hooksDir, 'hooks.json'), pluginHooksJson(r.spex));
|
|
93
|
+
// skills / agents / commands — the Claude-plugin layout, the SAME materialized contents as the native dirs.
|
|
94
|
+
reconcileBundleDirectory(join(bundle, 'skills'), new Set(r.skills.map((s) => s.name)));
|
|
95
|
+
reconcileBundleDirectory(join(bundle, 'agents'), new Set(r.agents.map((a) => `${a.name}.md`)));
|
|
96
|
+
reconcileBundleDirectory(join(bundle, 'commands'), new Set(r.commands.map((c) => `${c.name}.md`)));
|
|
97
|
+
for (const s of r.skills)
|
|
98
|
+
writeBundleFile(join(bundle, 'skills', s.name, 'SKILL.md'), s.content);
|
|
99
|
+
for (const a of r.agents)
|
|
100
|
+
writeBundleFile(join(bundle, 'agents', `${a.name}.md`), a.content);
|
|
101
|
+
for (const c of r.commands)
|
|
102
|
+
writeBundleFile(join(bundle, 'commands', `${c.name}.md`), c.content);
|
|
103
|
+
}
|
|
104
|
+
function reconcileBundleDirectory(dir, expectedNames) {
|
|
105
|
+
if (!existsSync(dir))
|
|
106
|
+
return;
|
|
107
|
+
for (const entry of readdirSync(dir)) {
|
|
108
|
+
if (!expectedNames.has(entry))
|
|
109
|
+
rmSync(join(dir, entry), { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function writeBundleFile(f, content) {
|
|
113
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
114
|
+
writeFileIfChanged(f, content);
|
|
115
|
+
}
|
|
116
|
+
// the INVERSE of emitPlugin — prune the bundle when its folder is DESELECTED ([[harness-delivery]] tracks the
|
|
117
|
+
// previously-emitted folders and cleans any the current set dropped, e.g. switching plugin→native or folder A→B).
|
|
118
|
+
// Identity-gated on the bundle's own plugin.json `name`, so it removes ONLY a spexcode bundle, never a folder
|
|
119
|
+
// the user populated with another plugin.
|
|
120
|
+
export function cleanPlugin(proj, folder) {
|
|
121
|
+
const bundle = pluginBundleDir(proj, folder);
|
|
122
|
+
const manifest = join(bundle, '.claude-plugin', 'plugin.json');
|
|
123
|
+
if (!existsSync(manifest))
|
|
124
|
+
return;
|
|
125
|
+
try {
|
|
126
|
+
if (String(JSON.parse(readFileSync(manifest, 'utf8'))?.name) !== PLUGIN_NAME)
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
rmSync(bundle, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
// the bundle version stamped into plugin.json — the monorepo root `spexcode` package version (the published
|
|
135
|
+
// artifact's version), falling back to the spec-cli package, then 0.0.0.
|
|
136
|
+
export function pluginVersion() {
|
|
137
|
+
for (const p of [join(PKG, '..', 'package.json'), join(PKG, 'package.json')]) {
|
|
138
|
+
try {
|
|
139
|
+
const v = JSON.parse(readFileSync(p, 'utf8'))?.version;
|
|
140
|
+
if (v)
|
|
141
|
+
return String(v);
|
|
142
|
+
}
|
|
143
|
+
catch { /* keep trying */ }
|
|
144
|
+
}
|
|
145
|
+
return '0.0.0';
|
|
146
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type Viewer = {
|
|
2
|
+
send: (data: Buffer) => void;
|
|
3
|
+
commitSize?: (cols: number, rows: number) => void;
|
|
4
|
+
};
|
|
5
|
+
export declare function attachViewer(id: string, viewer: Viewer): void;
|
|
6
|
+
export declare function hideViewer(id: string, viewer: Viewer): void;
|
|
7
|
+
export declare function detachViewer(id: string, viewer: Viewer): void;
|
|
8
|
+
export declare function resizeBridge(id: string, viewer: Viewer, colsValue: number, rowsValue: number): void;
|
|
9
|
+
export declare function forwardInput(id: string, viewer: Viewer, data: string): boolean;
|
|
10
|
+
export declare function superviseBridges(intervalMs?: number): void;
|