@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,308 @@
1
+ // SO_REUSEPORT is ENOTSUP on macOS/Node, so the supervisor owns the public port as a raw-TCP proxy and
2
+ // runs the Hono server as a child on a private port (raw piping carries WS upgrades too).
3
+ import net from 'node:net';
4
+ import http from 'node:http';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { existsSync } from 'node:fs';
8
+ import { stat, readdir } from 'node:fs/promises';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname, join } from 'node:path';
11
+ import { installProcessGuards } from '@spexcode/spec-core';
12
+ import { listenOrExit } from './listen.js';
13
+ import { resolvePublicConfig, startGateway, resolveDistDir } from './gateway.js';
14
+ import { publishEndpoint, dropOwnEndpoint } from './host.js';
15
+ import { repoRoot as servedRepoRoot } from '@spexcode/spec-core';
16
+ import { resolveProjectIdentity } from '@spexcode/spec-core';
17
+ import { startResourceMonitor } from './host-resources.js';
18
+ import { registerBackendInstance, unregisterBackendInstance } from './runtime-ownership.js';
19
+ import { sessionIdentityEnvVars } from './harness.js';
20
+ import { serverEntrypointArgs } from './tsx-bin.js';
21
+ // the supervisor OWNS the public port, so it must outlive any transient throw: an uncaught error here is
22
+ // logged and survived, never an exit that closes the port (and the tmux session) and takes the frontend down.
23
+ installProcessGuards();
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ const packageRoot = join(here, '..');
26
+ const sourceRoot = join(packageRoot, 'src');
27
+ const workspaceRoot = existsSync(sourceRoot) ? join(packageRoot, '..') : null;
28
+ const entryArgs = serverEntrypointArgs(packageRoot, here);
29
+ const publicPort = Number(process.env.PORT || 8787);
30
+ const projectRoot = servedRepoRoot(); // the actual git tree whose source/spec/config the child serves
31
+ // @@@ public mode ([[public-mode]]) - with `spex serve --public`, the supervisor is NOT the internet face:
32
+ // the gateway is. The raw-TCP proxy retreats to a loopback internal port (the trusted boundary local agents
33
+ // reach) and the password-gated TLS gateway takes the public port, proxying /api + WS back to that loopback
34
+ // port. Off → unchanged: the proxy itself owns the public port and SPEXCODE_API_URL points there.
35
+ const publicCfg = resolvePublicConfig(projectRoot);
36
+ // the port the raw-TCP proxy actually binds: a private loopback port in public mode, the public port otherwise.
37
+ const proxyPort = publicCfg ? await freePort() : publicPort;
38
+ // what launched agents inherit as their `spex` endpoint — ALWAYS the loopback proxy, so local agents never
39
+ // meet the password gate (loopback is the trust boundary). Equals the public port when public mode is off.
40
+ const childApiBase = `http://127.0.0.1:${proxyPort}`;
41
+ // An installed package only has compiled JavaScript. In a source workspace keep the development loop honest:
42
+ // observe the source closure, compile it, then make the supervisor's normal zero-downtime swap. A source edit
43
+ // must never cause a restart that still runs the old dist.
44
+ const watchRoots = workspaceRoot
45
+ ? [sourceRoot, join(workspaceRoot, 'spec-forge', 'src'), join(workspaceRoot, 'spec-eval', 'src'), join(workspaceRoot, 'packages', 'spec-core', 'src')]
46
+ : [here];
47
+ function buildWorkspace() {
48
+ if (!workspaceRoot)
49
+ return true;
50
+ console.log('[supervisor] building workspace artifacts');
51
+ const result = spawnSync('npm', ['run', 'build'], { cwd: workspaceRoot, stdio: 'inherit' });
52
+ if (result.status === 0)
53
+ return true;
54
+ console.error('[supervisor] workspace build failed — keeping current backend');
55
+ return false;
56
+ }
57
+ // @@@ instance identity - one id for this serve's whole lifetime, minted at supervisor start and handed to
58
+ // every child via env, so the endpoint record and the live backend answer with the SAME identity across
59
+ // zero-downtime reloads. The host gateway ([[host-gateway]]) validates a record by comparing this id (and the
60
+ // served root) against the live /api/instance answer — a recycled port serving a DIFFERENT project or a
61
+ // different serve generation fails the match and is treated as offline, never proxied to.
62
+ const instanceId = randomUUID();
63
+ // A backend is a project control plane, never the session that happened to invoke `spex serve`. Strip every
64
+ // adapter identity before any hot child is spawned; otherwise all backend work is falsely charged to that
65
+ // caller long after its session ends.
66
+ for (const key of sessionIdentityEnvVars())
67
+ delete process.env[key];
68
+ process.env.SPEXCODE_PROJECT_ROOT = projectRoot;
69
+ process.env.SPEXCODE_INSTANCE_ID = instanceId;
70
+ registerBackendInstance(instanceId, process.pid, projectRoot);
71
+ let current = null; // which internal port new proxy connections forward to
72
+ let reloading = false; // single-flight guard for reload()
73
+ let pending = false; // a code change arrived mid-reload → reload again when done
74
+ // grab an ephemeral port by binding :0, then release it for the child to claim (negligible rebind race).
75
+ function freePort() {
76
+ return new Promise((res, rej) => {
77
+ const s = net.createServer();
78
+ s.once('error', rej);
79
+ s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
80
+ });
81
+ }
82
+ // poll GET /health until 200; ~15s budget covers a cold Node + Hono start. False → keep old.
83
+ function waitHealthy(port, tries = 150) {
84
+ return new Promise((resolve) => {
85
+ const retry = (left) => { if (left <= 1)
86
+ resolve(false);
87
+ else
88
+ setTimeout(() => attempt(left - 1), 100); };
89
+ const attempt = (left) => {
90
+ const req = http.get({ host: '127.0.0.1', port, path: '/health', timeout: 1000 }, (r) => {
91
+ r.resume();
92
+ if (r.statusCode === 200)
93
+ resolve(true);
94
+ else
95
+ retry(left);
96
+ });
97
+ req.on('error', () => retry(left));
98
+ req.on('timeout', () => { req.destroy(); retry(left); });
99
+ };
100
+ attempt(tries);
101
+ });
102
+ }
103
+ // boot a fresh backend child on a free internal port and wait until it's serving.
104
+ async function boot() {
105
+ const port = await freePort();
106
+ // PORT pins the child's PRIVATE bind port; SPEXCODE_API_URL pins everything the child SPAWNS (launched
107
+ // sessions + their hooks) at the PUBLIC port, so a launched agent's own `spex` reaches the stable proxy
108
+ // and never inherits this ephemeral, soon-retired port. ALWAYS childApiBase, never the ambient
109
+ // process.env.SPEXCODE_API_URL: the env this serve itself inherited may carry ANOTHER project's backend
110
+ // (the exact misroute [[remote-client]]'s ladder exists to kill), and a worker's env is its routing
111
+ // LIFELINE — it must be a deterministic backend-injected fact, not an inheritance gamble.
112
+ const child = spawn(process.execPath, entryArgs, { stdio: 'inherit', env: { ...process.env, PORT: String(port), SPEXCODE_API_URL: childApiBase, SPEXCODE_INSTANCE_ID: instanceId } });
113
+ // if the ACTIVE backend dies unexpectedly (crash, OOM), restart it so the public port keeps serving.
114
+ // Planned retirement sets current to the NEW child first, so the old child's exit fails this identity
115
+ // check and is ignored. boot()'s ~5s health budget rate-limits any crash loop.
116
+ child.on('exit', (code, sig) => {
117
+ if (current?.child === child) {
118
+ console.error(`[supervisor] active backend exited (${code ?? sig}) — restarting`);
119
+ current = null;
120
+ void reload('crash');
121
+ }
122
+ });
123
+ if (await waitHealthy(port))
124
+ return { port, child };
125
+ try {
126
+ child.kill('SIGKILL');
127
+ }
128
+ catch { /* already gone */ }
129
+ return null;
130
+ }
131
+ // boot → health-gate → atomic flip → drain old. The flip is a single assignment; the old child is killed
132
+ // only after a drain delay, so a connection mid-flip is never refused.
133
+ async function reload(reason) {
134
+ if (reloading) {
135
+ pending = true;
136
+ return;
137
+ }
138
+ reloading = true;
139
+ try {
140
+ do {
141
+ pending = false;
142
+ if (!buildWorkspace())
143
+ break;
144
+ const next = await boot();
145
+ if (!next) {
146
+ console.error(`[supervisor] new backend failed health check (${reason}) — keeping current`);
147
+ break;
148
+ }
149
+ const old = current;
150
+ current = next; // atomic flip: new connections now route to `next`
151
+ console.log(`[supervisor] reloaded (${reason}) → backend :${next.port}`);
152
+ if (old)
153
+ setTimeout(() => { try {
154
+ old.child.kill('SIGTERM');
155
+ }
156
+ catch { /* already gone */ } }, 500);
157
+ } while (pending);
158
+ }
159
+ finally {
160
+ reloading = false;
161
+ }
162
+ }
163
+ // the public port: a raw byte pipe to the current backend. Works for HTTP and WS upgrades alike.
164
+ // @@@ connection reaping ([[spec-cli]]) - a raw TCP proxy must tear down the PAIR when EITHER end goes, or it
165
+ // leaks the still-open half. The wedge that started the mass-restore incident was exactly this: every
166
+ // client-side timeout-kill left the client's UPSTREAM socket half-open to the child (the old handler bailed
167
+ // only on `error`, so a clean FIN / a silent drop never reaped the upstream) — 135 leaked conns piled on the
168
+ // child and it looked dead. So a close on either side destroys BOTH (idempotent). The abandoned-but-silent
169
+ // case (no FIN/RST ever arrives) is reaped from the CHILD instead — its socket-level reaper (reaper.ts)
170
+ // destroys an idle/stalled socket, whose close then propagates here — so an active WS/SSE (not idle
171
+ // keep-alive) is never mistaken for abandoned and cut.
172
+ const proxy = net.createServer((client) => {
173
+ const target = current;
174
+ if (!target) {
175
+ client.destroy();
176
+ return;
177
+ }
178
+ const up = net.connect(target.port, '127.0.0.1');
179
+ client.setNoDelay(true);
180
+ up.setNoDelay(true); // proxy a request promptly — don't let Nagle add latency
181
+ const bail = () => { client.destroy(); up.destroy(); };
182
+ client.on('error', bail);
183
+ up.on('error', bail);
184
+ client.once('close', () => up.destroy()); // client abandoned → reap its upstream (THE leak that wedged :8787); nothing left to flush to a gone client
185
+ // upstream gone → drop the client half, but ONLY force it when the close was ABNORMAL: on a normal FIN,
186
+ // `up.pipe(client)` has already called `client.end()` (writableEnded), so let the client flush the last of a
187
+ // large response (e.g. /api/graph) rather than truncate it with a destroy; a crash/half-open close (no prior
188
+ // end) still gets reaped.
189
+ up.once('close', () => { if (!client.writableEnded)
190
+ client.destroy(); });
191
+ client.pipe(up);
192
+ up.pipe(client);
193
+ });
194
+ // @@@ endpoint record - this project's live backend endpoint, published into the per-project runtime tier
195
+ // (~/.spexcode/projects/<enc>/backend.json) only AFTER the public bind succeeds. It's what lets a bare
196
+ // `spex` run from this project's tree find ITS OWN backend instead of an env URL inherited from another
197
+ // project's ([[remote-client]]'s resolution ladder), and what the host gateway ([[host-gateway]]) reconciles
198
+ // its project list from. The write is ATOMIC (tmp + rename) and carries the serve's actual git toplevel +
199
+ // resolved identity — so a reader can VALIDATE the record against the live /api/instance answer,
200
+ // never just trust a URL. Readers health-probe before trusting, so a crashed serve leaves at worst a dead
201
+ // record that is ignored — never followed. The recorded URL is the LOOPBACK face local agents reach (equals
202
+ // the public port when public mode is off), never the password-gated gateway. A linked worktree therefore
203
+ // gets its own encoded slot and can never overwrite the main checkout's endpoint.
204
+ function recordEndpoint(url) {
205
+ try {
206
+ publishEndpoint({
207
+ version: 2,
208
+ url,
209
+ pid: process.pid,
210
+ instanceId,
211
+ root: projectRoot,
212
+ identity: resolveProjectIdentity(projectRoot, projectRoot),
213
+ startedAt: new Date().toISOString(),
214
+ });
215
+ }
216
+ catch (e) {
217
+ console.error(`[supervisor] could not record the backend endpoint (${e.message}) — cwd-based \`spex\` discovery won't find this backend`);
218
+ }
219
+ }
220
+ // best-effort removal on a clean stop, only if the record is OURS — matched by instanceId, so a newer serve
221
+ // that already overwrote the record (or another project's) is never deleted by a retiring one.
222
+ function dropEndpoint() {
223
+ try {
224
+ dropOwnEndpoint(instanceId, projectRoot);
225
+ }
226
+ catch { /* not ours / already gone */ }
227
+ }
228
+ const shutdown = () => { dropEndpoint(); unregisterBackendInstance(instanceId); try {
229
+ current?.child.kill('SIGTERM');
230
+ }
231
+ catch { /* */ } process.exit(0); };
232
+ process.on('SIGINT', shutdown);
233
+ process.on('SIGTERM', shutdown);
234
+ const first = await boot();
235
+ if (!first) {
236
+ console.error('[supervisor] initial backend failed to start');
237
+ process.exit(1);
238
+ }
239
+ current = first;
240
+ // reap the booted child if a port bind fails, so a "can't own my port → exit" never leaves a zombie child.
241
+ // SIGTERM lets the direct Node child release its backend port before the supervisor exits.
242
+ const reapChild = () => { unregisterBackendInstance(instanceId); try {
243
+ current?.child.kill('SIGTERM');
244
+ }
245
+ catch { /* already gone */ } };
246
+ if (publicCfg) {
247
+ // public mode: the raw proxy stays on loopback; the password-gated gateway owns the public port.
248
+ const distDir = resolveDistDir();
249
+ listenOrExit(proxy, proxyPort, { host: '127.0.0.1', label: 'supervisor (loopback proxy)', cleanup: reapChild, onListen: () => { recordEndpoint(childApiBase); console.log(`spec-cli supervisor on loopback :${proxyPort} (zero-downtime reloads, backend :${first.port})`); } });
250
+ startGateway({ publicPort, upstreamPort: proxyPort, password: publicCfg.password, tls: publicCfg.tls, distDir, onBindFail: reapChild });
251
+ }
252
+ else {
253
+ listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: () => { recordEndpoint(childApiBase); console.log(`spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})`); } });
254
+ }
255
+ startResourceMonitor();
256
+ // Watch the source closure in a workspace and the emitted directory in an installed package. Debounce a
257
+ // burst of writes (a merge touching several files across packages) into one rebuild and reload. A root that
258
+ // can't be watched (a package absent in some checkout) is logged and skipped — the supervisor owns the
259
+ // public port and must never die over a missing watch.
260
+ // fs.watch — even recursive — is NOT reliable for a long-lived supervisor: its inotify watches are silently
261
+ // dropped when a watched dir is rewritten (a git merge, a `materialize`) and NEVER re-established, so reloads
262
+ // quietly stop (observed: the watcher fired a few times then went deaf for hours). A cheap mtime poll can't go
263
+ // deaf: every 2s, take the newest .ts/.js/.json mtime across the trees; a jump triggers the debounced reload
264
+ // (a burst of writes still lands as one). Polling a few small src trees is negligible cost.
265
+ let timer;
266
+ const newestMtime = async (dir) => {
267
+ let max = 0;
268
+ let entries;
269
+ try {
270
+ entries = await readdir(dir, { withFileTypes: true });
271
+ }
272
+ catch {
273
+ return 0;
274
+ } // a tree absent in some checkout → skip
275
+ for (const e of entries) {
276
+ if (e.isDirectory()) {
277
+ if (e.name !== 'node_modules')
278
+ max = Math.max(max, await newestMtime(join(dir, e.name)));
279
+ }
280
+ else if (/\.(ts|js|mjs|json)$/.test(e.name)) {
281
+ try {
282
+ max = Math.max(max, (await stat(join(dir, e.name))).mtimeMs);
283
+ }
284
+ catch { /* raced unlink */ }
285
+ }
286
+ }
287
+ return max;
288
+ };
289
+ const scanMtime = async () => {
290
+ let m = 0;
291
+ for (const root of watchRoots)
292
+ m = Math.max(m, await newestMtime(root));
293
+ return m;
294
+ };
295
+ let lastMtime = await scanMtime(); // baseline at boot — only a LATER change reloads
296
+ let scanning = false;
297
+ setInterval(() => {
298
+ if (scanning)
299
+ return;
300
+ scanning = true;
301
+ void scanMtime().then((m) => {
302
+ if (m > lastMtime) {
303
+ lastMtime = m;
304
+ clearTimeout(timer);
305
+ timer = setTimeout(() => void reload('code change'), 150);
306
+ }
307
+ }).finally(() => { scanning = false; });
308
+ }, 2000).unref();
package/dist/tree.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ export type TreeNode = {
2
+ id: string;
3
+ parent: string | null;
4
+ title: string;
5
+ status: string;
6
+ version?: number;
7
+ drift?: number;
8
+ ghost?: boolean;
9
+ reviewSummary?: {
10
+ issues?: {
11
+ open: number;
12
+ };
13
+ evals?: {
14
+ stalePass: number;
15
+ staleFail: number;
16
+ };
17
+ };
18
+ };
19
+ export type TreeOpts = {
20
+ node?: string;
21
+ depth?: number;
22
+ color?: boolean;
23
+ };
24
+ export declare function renderTree(nodes: TreeNode[], opts?: TreeOpts): string;
25
+ export declare function treeJson(nodes: TreeNode[], opts?: TreeOpts): object[];
package/dist/tree.js ADDED
@@ -0,0 +1,98 @@
1
+ // @@@ spex graph - the CLI's human-readable graph view: the same assembled board the dashboard's
2
+ // tidy-tree renders, as an indented terminal tree. Pure presentation over buildBoard()'s nodes —
3
+ // no read path of its own; status colours and badge semantics mirror the dashboard (drift /
4
+ // stale-eval / open-issues counts). Governed by the spex-tree spec node.
5
+ // dashboard status palette mapped onto ANSI: merged=green, active=cyan (live/in-flight, distinct
6
+ // from the warning yellow), drift=yellow (the dashboard's warning colour), pending=muted grey.
7
+ const STATUS_ANSI = { merged: '32', active: '36', drift: '33', pending: '90' };
8
+ // stale-eval count: declared scenarios whose LATEST reading exists but is no longer fresh.
9
+ // board `evals` is already latest-per-scenario, so this is a straight filter — the same freshness
10
+ // axis the dashboard's grey ✓/✗ badges read (score.jsx readingScore).
11
+ function staleYatsu(n) {
12
+ const summary = n.reviewSummary?.evals;
13
+ return summary ? summary.stalePass + summary.staleFail : 0;
14
+ }
15
+ function childrenIndex(nodes) {
16
+ const byParent = new Map();
17
+ for (const n of nodes) {
18
+ const list = byParent.get(n.parent) ?? [];
19
+ list.push(n);
20
+ byParent.set(n.parent, list);
21
+ }
22
+ return byParent;
23
+ }
24
+ // resolve the display roots: --node picks one subtree; default is the forest of parentless nodes
25
+ // (a node whose parent id isn't on the board — a ghost mid-add, say — surfaces as a root rather
26
+ // than vanishing). Unknown --node throws (fail loud, caller prints + exits 2), never an empty tree.
27
+ function roots(nodes, nodeId) {
28
+ if (nodeId) {
29
+ const hit = nodes.find((n) => n.id === nodeId);
30
+ if (!hit)
31
+ throw new Error(`no spec node "${nodeId}" — spex graph lists every id; spex spec search <topic> finds one by intent`);
32
+ return [hit];
33
+ }
34
+ const ids = new Set(nodes.map((n) => n.id));
35
+ return nodes.filter((n) => n.parent === null || !ids.has(n.parent));
36
+ }
37
+ export function renderTree(nodes, opts = {}) {
38
+ const color = opts.color ?? false;
39
+ const c = (code, t) => (color ? `\x1b[${code}m${t}\x1b[0m` : t);
40
+ const byParent = childrenIndex(nodes);
41
+ const lines = [];
42
+ const badges = (n) => {
43
+ const parts = [];
44
+ if (n.ghost)
45
+ parts.push(c('90', 'ghost'));
46
+ if (n.drift)
47
+ parts.push(c('33', `drift:${n.drift}`));
48
+ const stale = staleYatsu(n);
49
+ if (stale)
50
+ parts.push(c('90', `stale:${stale}`));
51
+ if (n.reviewSummary?.issues?.open)
52
+ parts.push(c('31', `issues:${n.reviewSummary.issues.open}`));
53
+ return parts.length ? ' ' + parts.join(' ') : '';
54
+ };
55
+ const line = (n, prefix, branch) => {
56
+ const code = STATUS_ANSI[n.status] ?? '0';
57
+ // base state is invisible: merged (~80% of nodes) is the healthy ground state, already carried by
58
+ // the dot's colour — only deviations get a text label. Without colour the word IS the signal, so
59
+ // every status (merged included) prints in plain-text mode.
60
+ const label = color && n.status === 'merged' ? '' : ' ' + c(code, `[${n.status}]`);
61
+ const title = n.title && n.title !== n.id ? ' ' + c('90', '· ' + n.title) : '';
62
+ lines.push(`${prefix}${branch}${c(code, '●')} ${n.id}${label}${title}${badges(n)}`);
63
+ };
64
+ const walk = (n, prefix, branch, childPrefix, depth) => {
65
+ line(n, prefix, branch);
66
+ const kids = byParent.get(n.id) ?? [];
67
+ if (!kids.length)
68
+ return;
69
+ if (opts.depth !== undefined && depth >= opts.depth) {
70
+ lines.push(`${childPrefix}${c('90', `└─ … ${kids.length} more (raise --depth)`)}`);
71
+ return;
72
+ }
73
+ kids.forEach((k, i) => {
74
+ const last = i === kids.length - 1;
75
+ walk(k, childPrefix, last ? '└─ ' : '├─ ', childPrefix + (last ? ' ' : '│ '), depth + 1);
76
+ });
77
+ };
78
+ for (const r of roots(nodes, opts.node))
79
+ walk(r, '', '', '', 0);
80
+ return lines.join('\n');
81
+ }
82
+ // the machine exit: the same filtered subtree as NESTED objects, badge counts precomputed — a
83
+ // shaped view, not a replacement for `spex graph --json` (which stays the full flat payload). Pruned
84
+ // children degrade to their ids, so --depth still tells the machine what exists below the cut.
85
+ export function treeJson(nodes, opts = {}) {
86
+ const byParent = childrenIndex(nodes);
87
+ const shape = (n, depth) => {
88
+ const kids = byParent.get(n.id) ?? [];
89
+ const pruned = opts.depth !== undefined && depth >= opts.depth;
90
+ return {
91
+ id: n.id, title: n.title, status: n.status, version: n.version ?? 0,
92
+ drift: n.drift ?? 0, staleYatsu: staleYatsu(n), openIssues: n.reviewSummary?.issues?.open ?? 0,
93
+ ...(n.ghost ? { ghost: true } : {}),
94
+ children: pruned ? kids.map((k) => k.id) : kids.map((k) => shape(k, depth + 1)),
95
+ };
96
+ };
97
+ return roots(nodes, opts.node).map((r) => shape(r, 0));
98
+ }
@@ -0,0 +1,3 @@
1
+ export declare function tsxBin(pkgDir: string): string;
2
+ export declare function cliEntrypointArgs(pkgDir: string, callerDir: string): string[];
3
+ export declare function serverEntrypointArgs(pkgDir: string, callerDir: string): string[];
@@ -0,0 +1,41 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ // @@@ tsxBin - tsx's JS ENTRY (dist/cli.mjs), dev-or-published, run through `node` by the caller
4
+ // (`spawn(process.execPath, [tsxBin(pkgDir), entry, …])`). In the dev monorepo tsx sits in
5
+ // spec-cli/node_modules; in an installed `spexcode` package npm may hoist it to the consumer's node_modules
6
+ // — Node's own package resolver from spec-cli covers dev, global, and project-local installs in one rule.
7
+ // We resolve the .mjs entry rather than the `.bin/tsx` shim on purpose: the shim is an unspawnable sh
8
+ // script on Windows, so `node dist/cli.mjs` (identical to the shim on POSIX) is the one cross-platform form.
9
+ // `pkgDir` is the spec-cli directory.
10
+ export function tsxBin(pkgDir) {
11
+ try {
12
+ const req = createRequire(join(pkgDir, 'package.json'));
13
+ return join(dirname(req.resolve('tsx/package.json')), 'dist', 'cli.mjs');
14
+ }
15
+ catch {
16
+ throw new Error(`tsx runtime not found from ${pkgDir} — run \`npm install\` in the SpexCode package`);
17
+ }
18
+ }
19
+ function tsxLoader(pkgDir) {
20
+ try {
21
+ return createRequire(join(pkgDir, 'package.json')).resolve('tsx/esm');
22
+ }
23
+ catch {
24
+ throw new Error(`tsx runtime not found from ${pkgDir} — run \`npm install\` in the SpexCode package`);
25
+ }
26
+ }
27
+ // A direct source invocation is a development/test contract. A compiled launcher in the same workspace
28
+ // must still execute dist so an installed release never accidentally reintroduces a tsx requirement.
29
+ function sourceInvocation(pkgDir, callerDir) {
30
+ return resolve(callerDir) === join(resolve(pkgDir), 'src');
31
+ }
32
+ export function cliEntrypointArgs(pkgDir, callerDir) {
33
+ return sourceInvocation(pkgDir, callerDir)
34
+ ? ['--import', tsxLoader(pkgDir), join(pkgDir, 'src', 'cli.ts')]
35
+ : [join(pkgDir, 'dist', 'cli.js')];
36
+ }
37
+ export function serverEntrypointArgs(pkgDir, callerDir) {
38
+ return sourceInvocation(pkgDir, callerDir)
39
+ ? ['--import', tsxLoader(pkgDir), join(pkgDir, 'src', 'index.ts')]
40
+ : [join(pkgDir, 'dist', 'index.js')];
41
+ }
@@ -0,0 +1,3 @@
1
+ export declare function uninstall(targetArg: string | undefined, opts?: {
2
+ hooks?: boolean;
3
+ }): void;
@@ -0,0 +1,177 @@
1
+ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
2
+ import { basename, join, resolve, relative } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { HARNESSES } from './harness.js';
6
+ import { runtimeRoot, readConfig, mainCheckout } from '@spexcode/spec-core';
7
+ import { resolveHarnessTargets } from './harness-select.js';
8
+ import { loadSkillConfig, loadAgentConfig } from '@spexcode/spec-core';
9
+ import { dematerialize } from './materialize.js';
10
+ import { gitBinary } from '@spexcode/spec-core';
11
+ // the standard plugin-host folders a host agent scans (in addition to any named in spexcode.json's `harnesses`).
12
+ const DEFAULT_PLUGIN_HOSTS = ['.claude', '.codex', '.adopter-a'];
13
+ // Init and uninstall share one ownership source for generated git hooks: the shipped canonical templates.
14
+ // Exact bytes prove the destination is still our derivative; any user edit withdraws that ownership.
15
+ const HOOK_TEMPLATES = fileURLToPath(new URL('../templates/hooks', import.meta.url));
16
+ // is this dir a SpexCode plugin bundle? Either its folder name is the identity stamp, or its
17
+ // `.claude-plugin/plugin.json` declares `name: spexcode`. Read-gated so a user's other plugin is never touched.
18
+ function isSpexcodeBundle(dir) {
19
+ if (basename(dir) === 'spexcode')
20
+ return true;
21
+ const manifest = join(dir, '.claude-plugin', 'plugin.json');
22
+ if (!existsSync(manifest))
23
+ return false;
24
+ try {
25
+ return JSON.parse(readFileSync(manifest, 'utf8')).name === 'spexcode';
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ // sweep any spexcode-stamped plugin bundle (`<host>/plugins/spexcode`, or a `.claude-plugin/plugin.json` named
32
+ // spexcode) under the project's plugin-host folders. The bundle EMITTER is a later node, so a native-only install
33
+ // has nothing here today; the sweep keeps uninstall a true inverse once it lands (and cleans a hand-dropped one).
34
+ function sweepPluginBundles(proj, hosts) {
35
+ const removed = [];
36
+ for (const host of new Set(hosts)) {
37
+ const pluginsDir = join(proj, host, 'plugins');
38
+ if (!existsSync(pluginsDir))
39
+ continue;
40
+ for (const e of readdirSync(pluginsDir, { withFileTypes: true })) {
41
+ if (!e.isDirectory())
42
+ continue;
43
+ const dir = join(pluginsDir, e.name);
44
+ if (isSpexcodeBundle(dir)) {
45
+ rmSync(dir, { recursive: true, force: true });
46
+ removed.push(relative(proj, dir));
47
+ }
48
+ }
49
+ }
50
+ return removed;
51
+ }
52
+ // Arbitrary plugin landing folders are the one materialized path set that cannot be reconstructed from stamps
53
+ // alone, so materialize records them as data. Read every current per-tree ledger plus the legacy project-global
54
+ // ledger before uninstall removes the store; a stale bundle remains removable even after current config stopped
55
+ // naming its former host folder.
56
+ function pluginLedgerHosts(store) {
57
+ const ledgers = [join(store, 'plugin-folders')];
58
+ const trees = join(store, 'trees');
59
+ if (existsSync(trees)) {
60
+ for (const e of readdirSync(trees, { withFileTypes: true })) {
61
+ if (e.isDirectory())
62
+ ledgers.push(join(trees, e.name, 'plugin-folders'));
63
+ }
64
+ }
65
+ const hosts = [];
66
+ for (const ledger of ledgers) {
67
+ if (!existsSync(ledger))
68
+ continue;
69
+ hosts.push(...readFileSync(ledger, 'utf8').split('\n').map((line) => line.trim()).filter(Boolean));
70
+ }
71
+ return hosts;
72
+ }
73
+ // resolve the repo's shared git hooks dir (the common dir's hooks/), or null when <dir> isn't a git repo.
74
+ function hooksDir(proj) {
75
+ try {
76
+ const common = execFileSync(gitBinary(process.env), ['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
77
+ encoding: 'utf8',
78
+ stdio: ['ignore', 'pipe', 'ignore'],
79
+ }).trim();
80
+ return join(common, 'hooks');
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ // Remove only byte-identical products of the canonical hook templates. Enumerating the same template directory
87
+ // init copies keeps every generated hook covered without a second name list; modified and unrelated hooks survive.
88
+ function removeHooks(proj) {
89
+ const dir = hooksDir(proj);
90
+ if (!dir)
91
+ return [];
92
+ const removed = [];
93
+ for (const e of readdirSync(HOOK_TEMPLATES, { withFileTypes: true })) {
94
+ if (!e.isFile())
95
+ continue;
96
+ const hook = join(dir, e.name);
97
+ if (!existsSync(hook) || !readFileSync(hook).equals(readFileSync(join(HOOK_TEMPLATES, e.name))))
98
+ continue;
99
+ rmSync(hook, { force: true });
100
+ removed.push(e.name);
101
+ }
102
+ return removed;
103
+ }
104
+ export function uninstall(targetArg, opts = {}) {
105
+ const proj = resolve(targetArg ?? process.cwd());
106
+ console.log(`spex uninstall → ${proj}`);
107
+ // cwd = the project so the .plugins loaders read THIS tree's surface nodes (the live skill/agent names tell
108
+ // each adapter's clean() exactly which name-scoped on-demand files were its to remove).
109
+ const prevCwd = process.cwd();
110
+ let arts = { skills: [], agents: [] };
111
+ try {
112
+ process.chdir(proj);
113
+ arts = { skills: loadSkillConfig().map((s) => s.name), agents: loadAgentConfig().map((a) => a.name) };
114
+ }
115
+ catch {
116
+ // no readable .plugins (already partly torn down, or never adopted) — clean still strips the harness wiring.
117
+ }
118
+ finally {
119
+ process.chdir(prevCwd);
120
+ }
121
+ // 1+2. materialize(∅): every harness's artifacts (contract block, shim, trust, skills/agents), the managed
122
+ // .gitignore + info/exclude blocks, any legacy skip-worktree bit, and the content filter — the SAME
123
+ // erase phase every materialize runs, asserted against the empty policy. One inverse, never a parallel one.
124
+ dematerialize(proj, arts);
125
+ // 3. Locate the global per-project store and recover every current/legacy plugin landing folder from its
126
+ // ledgers BEFORE deleting it. The store is the runtime tier, not the user's tracked intent asset.
127
+ let store = null;
128
+ let ledgerHosts = [];
129
+ try {
130
+ store = runtimeRoot(proj);
131
+ }
132
+ catch {
133
+ store = null;
134
+ }
135
+ if (store)
136
+ ledgerHosts = pluginLedgerHosts(store);
137
+ // 4. Any spexcode-stamped plugin bundle under configured, standard, or ledger-recovered hosts. The ledger
138
+ // input is what closes plugin-folder A -> native/folder B even when the previous materialize never finished.
139
+ let pluginHosts = [...DEFAULT_PLUGIN_HOSTS, ...ledgerHosts];
140
+ try {
141
+ const targets = resolveHarnessTargets(readConfig(mainCheckout(proj)).harnesses);
142
+ pluginHosts = [...pluginHosts, ...targets.filter((t) => t.kind === 'plugin').map((t) => t.folder)];
143
+ }
144
+ catch {
145
+ // an illegal harnesses set doesn't block backout — fall through to the standard hosts.
146
+ }
147
+ const bundles = sweepPluginBundles(proj, pluginHosts);
148
+ // 5. The whole store: per-tree manifests/hashes/ledgers and sessions.
149
+ const removedStores = [];
150
+ for (const candidate of new Set([store].filter((path) => !!path))) {
151
+ if (!existsSync(candidate))
152
+ continue;
153
+ rmSync(candidate, { recursive: true, force: true });
154
+ removedStores.push(candidate);
155
+ }
156
+ console.log(`✓ dematerialized (contract blocks, shims, Codex trust, skills, sub-agents, ignore blocks, content filter) for ${HARNESSES.map((h) => h.id).join(', ')}`);
157
+ if (removedStores.length)
158
+ console.log(`✓ removed the global per-project store (${removedStores.join(', ')})`);
159
+ if (bundles.length)
160
+ console.log(`✓ removed plugin bundle(s): ${bundles.join(', ')}`);
161
+ // Git hooks are per-clone and may carry user logic → preserved unless --hooks (and even then only while
162
+ // byte-identical to a canonical generated template; a user edit withdraws our ownership).
163
+ if (opts.hooks) {
164
+ const removed = removeHooks(proj);
165
+ if (removed.length)
166
+ console.log(`✓ removed git hooks (${removed.join(', ')})`);
167
+ else
168
+ console.log('• no spexcode git hooks to remove');
169
+ }
170
+ else {
171
+ console.log('• left git hooks in place (per-clone; pass --hooks to remove the spexcode ones)');
172
+ }
173
+ console.log(`
174
+ SpexCode wiring removed. Your spec data is untouched:
175
+ • .spec/ (including .plugins/) and spexcode.json remain — your tracked intent is never deleted by uninstall.
176
+ • To re-adopt later: \`spex init\` regenerates the shims, contract, trust, and global store.`);
177
+ }