@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.
Files changed (201) hide show
  1. package/README.md +95 -0
  2. package/bin/spex.mjs +76 -0
  3. package/dist/attach.d.ts +2 -0
  4. package/dist/attach.js +48 -0
  5. package/dist/claude-headless.d.ts +33 -0
  6. package/dist/claude-headless.js +307 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1859 -0
  9. package/dist/client.d.ts +111 -0
  10. package/dist/client.js +476 -0
  11. package/dist/cockpit.d.ts +7 -0
  12. package/dist/cockpit.js +26 -0
  13. package/dist/codex-headless.d.ts +1 -0
  14. package/dist/codex-headless.js +7 -0
  15. package/dist/codex-runtime-generations.d.ts +53 -0
  16. package/dist/codex-runtime-generations.js +686 -0
  17. package/dist/commit-surgery.d.ts +1 -0
  18. package/dist/commit-surgery.js +90 -0
  19. package/dist/contract-filter.d.ts +14 -0
  20. package/dist/contract-filter.js +219 -0
  21. package/dist/dashboard-assets.d.ts +6 -0
  22. package/dist/dashboard-assets.js +50 -0
  23. package/dist/delivery-queue.d.ts +23 -0
  24. package/dist/delivery-queue.js +179 -0
  25. package/dist/doctor.d.ts +9 -0
  26. package/dist/doctor.js +547 -0
  27. package/dist/eval-host.d.ts +1 -0
  28. package/dist/eval-host.js +19 -0
  29. package/dist/execution-trace.d.ts +26 -0
  30. package/dist/execution-trace.js +475 -0
  31. package/dist/file-write.d.ts +2 -0
  32. package/dist/file-write.js +26 -0
  33. package/dist/flat.d.ts +89 -0
  34. package/dist/flat.js +1076 -0
  35. package/dist/gateway-auth.d.ts +56 -0
  36. package/dist/gateway-auth.js +168 -0
  37. package/dist/gateway-hub.d.ts +28 -0
  38. package/dist/gateway-hub.js +361 -0
  39. package/dist/gateway.d.ts +37 -0
  40. package/dist/gateway.js +503 -0
  41. package/dist/graphCache.d.ts +27 -0
  42. package/dist/graphCache.js +716 -0
  43. package/dist/graphSnapshot.d.ts +14 -0
  44. package/dist/graphSnapshot.js +35 -0
  45. package/dist/graphStream.d.ts +85 -0
  46. package/dist/graphStream.js +1195 -0
  47. package/dist/guidance-catalog.d.ts +64 -0
  48. package/dist/guidance-catalog.js +166 -0
  49. package/dist/guide.d.ts +8 -0
  50. package/dist/guide.js +684 -0
  51. package/dist/harness-select.d.ts +18 -0
  52. package/dist/harness-select.js +62 -0
  53. package/dist/harness.d.ts +325 -0
  54. package/dist/harness.js +3026 -0
  55. package/dist/headless-controller.d.ts +9 -0
  56. package/dist/headless-controller.js +47 -0
  57. package/dist/help.d.ts +10 -0
  58. package/dist/help.js +574 -0
  59. package/dist/hook-prompts.d.ts +18 -0
  60. package/dist/hook-prompts.js +122 -0
  61. package/dist/hooks.d.ts +1 -0
  62. package/dist/hooks.js +27 -0
  63. package/dist/host-resources.d.ts +97 -0
  64. package/dist/host-resources.js +795 -0
  65. package/dist/host.d.ts +88 -0
  66. package/dist/host.js +663 -0
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +899 -0
  69. package/dist/init.d.ts +1 -0
  70. package/dist/init.js +284 -0
  71. package/dist/issues-cli.d.ts +6 -0
  72. package/dist/issues-cli.js +334 -0
  73. package/dist/issues.d.ts +69 -0
  74. package/dist/issues.js +189 -0
  75. package/dist/lint.d.ts +34 -0
  76. package/dist/lint.js +476 -0
  77. package/dist/listen.d.ts +7 -0
  78. package/dist/listen.js +24 -0
  79. package/dist/localIssues.d.ts +65 -0
  80. package/dist/localIssues.js +582 -0
  81. package/dist/login-page.d.ts +5 -0
  82. package/dist/login-page.js +86 -0
  83. package/dist/loop-in.d.ts +23 -0
  84. package/dist/loop-in.js +68 -0
  85. package/dist/machine-peer.d.ts +69 -0
  86. package/dist/machine-peer.js +603 -0
  87. package/dist/materialize.d.ts +18 -0
  88. package/dist/materialize.js +468 -0
  89. package/dist/mentions.d.ts +50 -0
  90. package/dist/mentions.js +117 -0
  91. package/dist/opencode-headless.d.ts +4 -0
  92. package/dist/opencode-headless.js +208 -0
  93. package/dist/opencode.d.ts +3 -0
  94. package/dist/opencode.js +142 -0
  95. package/dist/pi-harness.d.ts +3 -0
  96. package/dist/pi-harness.js +129 -0
  97. package/dist/pi-headless.d.ts +25 -0
  98. package/dist/pi-headless.js +196 -0
  99. package/dist/plugin-harness.d.ts +21 -0
  100. package/dist/plugin-harness.js +146 -0
  101. package/dist/pty-bridge.d.ts +10 -0
  102. package/dist/pty-bridge.js +404 -0
  103. package/dist/pty-helper.d.mts +1 -0
  104. package/dist/pty-helper.mjs +101 -0
  105. package/dist/pty-native-helper.d.mts +2 -0
  106. package/dist/pty-native-helper.mjs +23 -0
  107. package/dist/public-graph.d.ts +46 -0
  108. package/dist/public-graph.js +56 -0
  109. package/dist/ranker.d.ts +15 -0
  110. package/dist/ranker.js +188 -0
  111. package/dist/reaper.d.ts +6 -0
  112. package/dist/reaper.js +81 -0
  113. package/dist/reviews.d.ts +189 -0
  114. package/dist/reviews.js +350 -0
  115. package/dist/runtime-guard.d.ts +6 -0
  116. package/dist/runtime-guard.js +37 -0
  117. package/dist/runtime-ownership.d.ts +21 -0
  118. package/dist/runtime-ownership.js +84 -0
  119. package/dist/search.bench.d.mts +1 -0
  120. package/dist/search.bench.mjs +75 -0
  121. package/dist/search.d.ts +20 -0
  122. package/dist/search.js +61 -0
  123. package/dist/session-cursors.d.ts +14 -0
  124. package/dist/session-cursors.js +82 -0
  125. package/dist/session-declarations.d.ts +13 -0
  126. package/dist/session-declarations.js +126 -0
  127. package/dist/session-execution.d.ts +10 -0
  128. package/dist/session-execution.js +70 -0
  129. package/dist/session-files.d.ts +28 -0
  130. package/dist/session-files.js +130 -0
  131. package/dist/session-follow.d.ts +31 -0
  132. package/dist/session-follow.js +179 -0
  133. package/dist/session-reparent.d.ts +10 -0
  134. package/dist/session-reparent.js +33 -0
  135. package/dist/session-timeline.d.ts +51 -0
  136. package/dist/session-timeline.js +233 -0
  137. package/dist/session-web.d.ts +24 -0
  138. package/dist/session-web.js +141 -0
  139. package/dist/sessions.d.ts +412 -0
  140. package/dist/sessions.js +4239 -0
  141. package/dist/sh.d.ts +1 -0
  142. package/dist/sh.js +3 -0
  143. package/dist/shim-runtime.d.ts +1 -0
  144. package/dist/shim-runtime.js +161 -0
  145. package/dist/slash-commands.d.ts +9 -0
  146. package/dist/slash-commands.js +307 -0
  147. package/dist/source-files.d.ts +9 -0
  148. package/dist/source-files.js +113 -0
  149. package/dist/supervise.d.ts +1 -0
  150. package/dist/supervise.js +308 -0
  151. package/dist/tree.d.ts +25 -0
  152. package/dist/tree.js +98 -0
  153. package/dist/tsx-bin.d.ts +3 -0
  154. package/dist/tsx-bin.js +41 -0
  155. package/dist/uninstall.d.ts +3 -0
  156. package/dist/uninstall.js +177 -0
  157. package/dist/uploads.d.ts +36 -0
  158. package/dist/uploads.js +243 -0
  159. package/dist/worktree-sources.d.ts +1 -0
  160. package/dist/worktree-sources.js +47 -0
  161. package/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  162. package/hooks/compat/mark-active-sed-v0.fixture +46 -0
  163. package/hooks/dispatch.sh +105 -0
  164. package/hooks/harness.sh +272 -0
  165. package/package.json +47 -0
  166. package/templates/hooks/commit-msg +14 -0
  167. package/templates/hooks/post-checkout +25 -0
  168. package/templates/hooks/post-merge +34 -0
  169. package/templates/hooks/pre-commit +120 -0
  170. package/templates/hooks/prepare-commit-msg +35 -0
  171. package/templates/hooks/reference-transaction +105 -0
  172. package/templates/spec/project/.plugins/commands/extract/spec.md +66 -0
  173. package/templates/spec/project/.plugins/commands/regroup/spec.md +25 -0
  174. package/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  175. package/templates/spec/project/.plugins/commands/spec.md +30 -0
  176. package/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  177. package/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  178. package/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
  179. package/templates/spec/project/.plugins/core/idle/idle.sh +15 -0
  180. package/templates/spec/project/.plugins/core/idle/spec.md +13 -0
  181. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +59 -0
  182. package/templates/spec/project/.plugins/core/mark-active/spec.md +22 -0
  183. package/templates/spec/project/.plugins/core/session-fail/fail.sh +12 -0
  184. package/templates/spec/project/.plugins/core/session-fail/spec.md +15 -0
  185. package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +43 -0
  186. package/templates/spec/project/.plugins/core/spec-first/spec.md +17 -0
  187. package/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +66 -0
  188. package/templates/spec/project/.plugins/core/spec-of-file/spec.md +15 -0
  189. package/templates/spec/project/.plugins/core/spec.md +22 -0
  190. package/templates/spec/project/.plugins/core/stop-gate/spec.md +23 -0
  191. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +149 -0
  192. package/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
  193. package/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
  194. package/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
  195. package/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
  196. package/templates/spec/project/.plugins/prompts/spec.md +24 -0
  197. package/templates/spec/project/.plugins/skills/distill/digest.mjs +136 -0
  198. package/templates/spec/project/.plugins/skills/distill/spec.md +67 -0
  199. package/templates/spec/project/.plugins/skills/spec.md +19 -0
  200. package/templates/spec/project/.plugins/spec.md +23 -0
  201. 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;