heyspex 0.2.5 → 0.2.6

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 (44) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/bin/hs.mjs +281 -30
  3. package/dev-factory/lib/dispatch-guard.mjs +75 -0
  4. package/dev-factory/lib/runner-path.mjs +51 -0
  5. package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
  6. package/dist/cli/hs-connect.mjs +20 -1
  7. package/dist/cli/hs-defer.mjs +3802 -3744
  8. package/dist/cli/hs-events.mjs +1296 -0
  9. package/dist/cli/hs-export-events.mjs +1252 -0
  10. package/dist/cli/hs-handoff.mjs +2576 -0
  11. package/dist/cli/hs-index-rebuild.mjs +209 -36
  12. package/dist/cli/hs-index-reconcile.mjs +212 -39
  13. package/dist/cli/hs-init.mjs +20 -1
  14. package/dist/cli/hs-install.mjs +20 -1
  15. package/dist/cli/hs-join.mjs +20 -1
  16. package/dist/cli/hs-next-launch.mjs +2698 -0
  17. package/dist/cli/hs-pin.mjs +1336 -0
  18. package/dist/cli/hs-prereq.mjs +1380 -0
  19. package/dist/cli/hs-revalidate.mjs +3257 -0
  20. package/dist/cli/hs-spawn.mjs +12377 -0
  21. package/dist/cli/hs-test.mjs +1046 -0
  22. package/dist/cli/hs-trash.mjs +362 -0
  23. package/dist/cli/hs-trust.mjs +197 -0
  24. package/dist/cli/hs-vault-pull.mjs +214 -41
  25. package/dist/cli/hs-vault-push.mjs +212 -39
  26. package/dist/cli-templates/harness-commands/claude/hs-res.md +71 -63
  27. package/dist/cli-templates/harness-commands/cursor/hs-res.md +66 -58
  28. package/docs/copilot-plugin-install.md +2 -2
  29. package/package.json +261 -257
  30. package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
  31. package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
  32. package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
  33. package/scripts/claude-statusline/statusline-command.mjs +287 -27
  34. package/scripts/hs-companion.mjs +8 -0
  35. package/scripts/install-claude-code-hooks.mjs +104 -3
  36. package/scripts/install-copilot-hooks.mjs +21 -1
  37. package/scripts/install-cursor-hooks.mjs +4 -3
  38. package/scripts/lib/capture-execution-guard.mjs +221 -0
  39. package/scripts/lib/customer-kit.mjs +48 -0
  40. package/scripts/lib/hs-companion-settings.mjs +25 -0
  41. package/scripts/record-grounding.mjs +38 -0
  42. package/vendor/heyspex-mcp/http-transport.mjs +1 -1
  43. package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
  44. package/bin/postinstall.mjs +0 -88
@@ -0,0 +1,734 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * spexcrawl-cli.mjs — CLI bridge from a Claude subagent (Bash tool only, no JS imports) to
4
+ * SpexCrawl: search/gather through the user's HeySpex Companion, brokered by the spex-console.
5
+ *
6
+ * THE GAP THIS CLOSES ([[HS-AIV8-G805-DIAG-hs-res-declares-spexcrawl-primary-but]]): the
7
+ * 2026-08-14 owner ruling makes SpexCrawl-via-Companion the PRIMARY research gather method
8
+ * (`.heyspex/config.yaml` default_source: spexcrawl-companion), but until this CLI existed no
9
+ * agent-reachable bridge did — every /hs-res run fell straight to the permission-gated Firecrawl
10
+ * fallback. This is the SpexCrawl sibling of firecrawl-cli.mjs: same verbs, same --json shape
11
+ * intent, same machine-readable failure prefix + exit-code conventions, so the /hs-res skill
12
+ * swaps backends without reshaping its steps.
13
+ *
14
+ * HOW IT WORKS (asynchronous — results come from the user's own machine):
15
+ * 1. POST <console>/api/agent/gather-jobs queues the job as a launch intent.
16
+ * 2. The HeySpex Companion app polls intents, searches/fetches/cleans pages LOCALLY
17
+ * (the user's browser context, robots-respecting), and uploads pages back to the console.
18
+ * 3. This CLI polls GET <console>/api/agent/gather-jobs/<jobId> every POLL_INTERVAL_MS until
19
+ * status "complete" (or --timeout), then prints the stored cleaned pages.
20
+ *
21
+ * PREREQUISITE (stated in every failure line so nobody has to dig): the HeySpex Companion
22
+ * tray app must be running — it is the only thing that executes gather jobs. Since HS-V9WH
23
+ * its relay polls loopback consoles too, so a same-machine dev console needs no LAN bind;
24
+ * only a Companion on a DIFFERENT machine needs the console in LAN mode
25
+ * (`npm run console:log:lan`). Without a Companion, jobs queue forever and this CLI exits 3
26
+ * at the poll timeout.
27
+ *
28
+ * FAILURE CONTRACT (mirrors FIRECRAWL_UNAVAILABLE so calling skill prompts can grep one prefix):
29
+ * - unreachable console, non-2xx that is not a 400, or poll timeout -> print a single line
30
+ * starting `SPEXCRAWL_UNAVAILABLE: ...` (including the LAN-mode hint) and exit 3.
31
+ * On the POLL leg only, a short streak of transient read failures is ridden out first
32
+ * (MAX_TRANSIENT_POLL_FAILURES) — the job is already filed and the Companion keeps
33
+ * working through a console blip, so one hiccup must not throw away a finished gather.
34
+ * - a 400 from the API -> print the server's message and exit 1 (the request was wrong,
35
+ * falling back to another backend would not fix it).
36
+ *
37
+ * Usage:
38
+ * node dev-factory/spexcrawl/spexcrawl-cli.mjs search "<query>" [--json] [--timeout SECONDS]
39
+ * node dev-factory/spexcrawl/spexcrawl-cli.mjs scrape "<url>" [<url>...] [--json] [--timeout SECONDS]
40
+ * (`gather` is accepted as an alias of `scrape` — it is the vault-native verb for the same job.)
41
+ * node dev-factory/spexcrawl/spexcrawl-cli.mjs crawl "<site>" [--max-pages N] [--max-depth N]
42
+ * [--json] [--timeout SECONDS]
43
+ * (walks a whole site — sitemap + same-origin links — instead of fetching known addresses;
44
+ * default --timeout is longer (600s) than search/scrape/gather's 180s, since a walk is bigger
45
+ * than a scrape. [[HS-QHES-90JL-TASK-crawl-reaches-agents-console-route]])
46
+ *
47
+ * Exit codes: 0 ok · 1 bad request / server-rejected input (server message printed) ·
48
+ * 3 SPEXCRAWL_UNAVAILABLE (unreachable / non-2xx / poll timeout — the
49
+ * stop-and-ask-before-Firecrawl signal) · 64 usage error (bad args).
50
+ */
51
+ import { readFileSync } from "node:fs";
52
+ import { homedir } from "node:os";
53
+ import { dirname, join } from "node:path";
54
+ import { fileURLToPath } from "node:url";
55
+
56
+ // The ONE shared console-URL resolver ([[HS-GD45-MCPC-TASK-mcp-cloud-auth-shared-console-resolver]])
57
+ // — env → ~/.heyspex/credentials.yaml console_url → mode-derived default, so this CLI flips
58
+ // with `hs dev` / `hs login` like every other consumer instead of hand-rolling its own
59
+ // env-only lookup.
60
+ import { resolveConsoleUrl as resolveConsoleUrlShared } from "../../scripts/lib/console-url-resolver.mjs";
61
+ import { parseRepoBinding } from "../../vendor/heyspex-mcp/vault-binding.mjs";
62
+
63
+ export const DEFAULT_CONSOLE_URL = "http://localhost:3000";
64
+ export const DEFAULT_TIMEOUT_MS = 180_000;
65
+ /** `crawl`'s default poll timeout — a whole-site walk legitimately runs longer
66
+ * than a single search/scrape/gather round trip. */
67
+ export const DEFAULT_CRAWL_TIMEOUT_MS = 600_000;
68
+ export const POLL_INTERVAL_MS = 3_000;
69
+ /**
70
+ * How many CONSECUTIVE transient read failures the poll loop rides out before declaring the
71
+ * console unavailable ([[HS-FBZ3-NWVU-DIAG-spexcrawl-companion-connectivity]]). Read failures
72
+ * are independent of the job: the Companion keeps working through a console blip. Bounded and
73
+ * still subject to the overall `--timeout`, so a console that is genuinely down costs at most
74
+ * this many extra poll intervals before the Firecrawl fallback fires. Any answered poll resets
75
+ * the streak. A 400 is NOT transient and is never retried.
76
+ */
77
+ export const MAX_TRANSIENT_POLL_FAILURES = 3;
78
+
79
+ /** Appended to every SPEXCRAWL_UNAVAILABLE line — the prerequisite must be stated by the
80
+ * CLI's own error message, not left to archaeology (diagnosis AC3). Since HS-V9WH the
81
+ * Companion polls loopback consoles too, so same-machine dev needs no LAN mode — LAN
82
+ * bind only matters when the Companion runs on a different machine than the console. */
83
+ export const LAN_HINT =
84
+ "if the Companion is not picking up jobs, check the HeySpex Companion tray app is running " +
85
+ "(rebuilt after 2026-08-21 — older builds ignore loopback consoles); a Companion on " +
86
+ "another machine additionally needs the console in LAN mode (npm run console:log:lan)";
87
+
88
+ /** Appended instead of {@link LAN_HINT} when the console REFUSED the bearer (401/403). That is a
89
+ * server-side auth verdict on this machine's token, not a Companion liveness problem — sending
90
+ * the reader to check the tray app hid a wrong-route bug for a day
91
+ * ([[HS-WN3F-LY85-DIAG-post-api-agent-gather-jobs-returns-401]] AC/fix 4). */
92
+ export const AUTH_HINT =
93
+ "the console refused this machine's bearer (clerk.companion_token in ~/.heyspex/credentials.yaml, " +
94
+ "or HEYSPEX_COMPANION_TOKEN) — the Companion being up or down does not matter here; re-pair this " +
95
+ "machine to the console so a fresh, unrevoked token is written, then retry";
96
+
97
+ /** Appended instead of {@link LAN_HINT} when the console refused the job because no Companion
98
+ * polls its vault ([[HS-L4LC]]). Names the vault-mismatch case: the Companion may be running
99
+ * fine, just bound (vaultRepoPath) to a different vault than the one this job named. */
100
+ export function companionVaultHint(vaultId) {
101
+ const v = jobVault();
102
+ const run = v.runVaultId && v.runVaultId !== vaultId ? `; this run's own vault is ${v.runVaultId}` : "";
103
+ return (
104
+ `no Companion is polling vault ${vaultId || "(none)"} (resolved via ${v.source}${run}) — ` +
105
+ "a Companion claims jobs only for the ONE vault its companion.settings.json vaultRepoPath names: " +
106
+ "if the tray app is running it is bound to another vault (set HEYSPEX_SPEXCRAWL_VAULT_ID to that " +
107
+ "vault's id, or point vaultRepoPath here), otherwise start the HeySpex Companion tray app"
108
+ );
109
+ }
110
+
111
+ // ── console endpoint + auth resolution ─────────────────────────────────────────────────────────
112
+ // Delegates to the shared resolver (env override still wins first); no `repoRoot` here, so the
113
+ // admin-local discovery-file rung is never consulted by this CLI (unchanged from before).
114
+ export function resolveConsoleUrl(env = process.env) {
115
+ return resolveConsoleUrlShared({ env });
116
+ }
117
+
118
+ /**
119
+ * Bearer token for /api/agent/*: env HEYSPEX_COMPANION_TOKEN first (LAN dev / CI), else the
120
+ * logged-in Companion session token (~/.heyspex/credentials.yaml, clerk.companion_token) — the
121
+ * SAME source the Companion itself reads (FileCompanionCredentialReader) and the same regex
122
+ * resolution as dev-factory/fleet/manager.mjs fleetToken(). Null = send no auth header, which
123
+ * mirrors the Companion's own optional-bearer behavior (the dev-loopback console accepts it).
124
+ */
125
+ export function resolveConsoleToken(env = process.env, readFile = readFileSync) {
126
+ const fromEnv = String(env.HEYSPEX_COMPANION_TOKEN || "").trim();
127
+ if (fromEnv) return fromEnv;
128
+ try {
129
+ const yaml = readFile(join(homedir(), ".heyspex", "credentials.yaml"), "utf8");
130
+ const clerk = yaml.match(/^clerk:\s*$([\s\S]*?)(?=^\S|\s*$(?![\s\S]))/m)?.[1] ?? yaml;
131
+ const m = clerk.match(/^\s*companion_token:\s*["']?([^"'\r\n#]+)/m);
132
+ return m?.[1]?.trim() || null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The vault this run is bound to, for `x-heyspex-vault-id`. The console refuses a bearer request
140
+ * that names no vault (409 VAULT_SCOPE_MISMATCH / vault-scope-required), so an unscoped POST to
141
+ * /api/agent/gather-jobs never reaches the Companion. Sources, first hit wins: env
142
+ * HEYSPEX_VAULT_ID; `vault.id` of a repo-binding heyspex.yaml (or legacy `vault_id:`) at the cwd (or an ancestor); then the vault root
143
+ * the repo's .mcp.json pins in mcpServers.heyspex.env.HEYSPEX_VAULT_ROOT (a code repo attached to a
144
+ * sibling vault). Null = send no header, which the console will refuse with its own hint.
145
+ */
146
+ /** Vault id named by `<dir>/heyspex.yaml` — repo-binding `vault.id` first, legacy flat `vault_id:` last. */
147
+ function vaultIdInDir(dir, readFile = readFileSync) {
148
+ try {
149
+ const text = readFile(join(dir, "heyspex.yaml"), "utf8");
150
+ return parseRepoBinding(text)?.id || text.match(/^vault_id:\s*["']?([^"'\s#]+)/m)?.[1] || null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ export function resolveVaultId(env = process.env, readFile = readFileSync, cwd = process.cwd()) {
157
+ const fromEnv = String(env.HEYSPEX_VAULT_ID || "").trim();
158
+ if (fromEnv) return fromEnv;
159
+ const idIn = (dir) => vaultIdInDir(dir, readFile);
160
+ // HEYSPEX_VAULT_ROOT is the documented explicit override (owner ruling 2026-10-14 resolution
161
+ // order: --vault -> HEYSPEX_VAULT_ROOT -> walk up). It used to be ignored here, so a run aimed at
162
+ // another vault silently resolved the cwd's binding instead ([[HS-L4LC]] root cause 1).
163
+ const root = String(env.HEYSPEX_VAULT_ROOT || "").trim();
164
+ if (root && !root.includes("${")) {
165
+ const id = idIn(root);
166
+ if (id) return id;
167
+ }
168
+ for (let dir = cwd, prev = null; dir !== prev; prev = dir, dir = dirname(dir)) {
169
+ const id = idIn(dir);
170
+ if (id) return id;
171
+ }
172
+ try {
173
+ const pinned = JSON.parse(readFile(join(cwd, ".mcp.json"), "utf8"))?.mcpServers?.heyspex?.env?.HEYSPEX_VAULT_ROOT;
174
+ if (typeof pinned === "string" && pinned.trim() && !pinned.includes("${")) return idIn(pinned.trim());
175
+ } catch {
176
+ // no .mcp.json / unparseable: fall through to null
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Where the local Companion keeps `companion.settings.json`, in the order its own
183
+ * CompanionPathLocator.SettingsPath() would find it: explicit env override, the source checkout
184
+ * this CLI lives in (`<repo>/companion/`, a dev-built Companion), then the per-user install dir.
185
+ */
186
+ export function companionSettingsCandidates(env = process.env) {
187
+ const out = [];
188
+ const fromEnv = String(env.HEYSPEX_COMPANION_SETTINGS || "").trim();
189
+ if (fromEnv) out.push(fromEnv);
190
+ try {
191
+ const here = dirname(fileURLToPath(import.meta.url)); // <repo>/dev-factory/spexcrawl
192
+ out.push(join(here, "..", "..", "companion", "companion.settings.json"));
193
+ } catch {
194
+ // non-file URL (bundled): skip the checkout rung
195
+ }
196
+ const localAppData = String(env.LOCALAPPDATA || "").trim();
197
+ if (localAppData) out.push(join(localAppData, "HeySpex", "companion.settings.json"));
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * The vault the local Companion polls launch intents for — the ONLY vault whose gather jobs it
203
+ * will ever claim. The Companion stamps `x-heyspex-vault-id` from the `heyspex.yaml` under its
204
+ * single `vaultRepoPath` (RelayEnvironment.ResolveVaultId), and the console drains intents per
205
+ * (owner, vault), so a job filed under any other vault sits "pending" until the CLI times out
206
+ * ([[HS-L4LC]]: every HS-NPMN scrape for the MLC vault). Returns
207
+ * `{ vaultId, vaultRepoPath, settingsPath }` or null when no Companion settings are readable.
208
+ */
209
+ export function resolveCompanionVault(env = process.env, readFile = readFileSync) {
210
+ for (const settingsPath of companionSettingsCandidates(env)) {
211
+ let text;
212
+ try {
213
+ text = readFile(settingsPath, "utf8");
214
+ } catch {
215
+ continue;
216
+ }
217
+ const raw = text.match(/"vaultRepoPath"\s*:\s*("(?:[^"\\]|\\.)*")/)?.[1];
218
+ let vaultRepoPath = null;
219
+ try {
220
+ vaultRepoPath = raw ? JSON.parse(raw).trim() : null;
221
+ } catch {
222
+ vaultRepoPath = null;
223
+ }
224
+ if (!vaultRepoPath) continue;
225
+ const vaultId = vaultIdInDir(vaultRepoPath, readFile);
226
+ if (vaultId) return { vaultId, vaultRepoPath, settingsPath };
227
+ }
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * The vault a SpexCrawl job is FILED under (`x-heyspex-vault-id` on the POST and the poll).
233
+ * A gather/research/crawl job is a work order for the user's own Companion, not vault content —
234
+ * the pages come straight back to this CLI, and whatever the caller saves lands in its own vault
235
+ * through its own write path. So the job is addressed to the vault the Companion actually
236
+ * listens on. Order: env HEYSPEX_SPEXCRAWL_VAULT_ID (explicit override) -> the local Companion's
237
+ * vault -> the run's own vault ({@link resolveVaultId}). `runVaultId` is reported so a mismatch
238
+ * is visible instead of silent.
239
+ */
240
+ export function resolveJobVault(env = process.env, readFile = readFileSync, cwd = process.cwd()) {
241
+ const runVaultId = resolveVaultId(env, readFile, cwd);
242
+ const override = String(env.HEYSPEX_SPEXCRAWL_VAULT_ID || "").trim();
243
+ if (override) return { vaultId: override, source: "HEYSPEX_SPEXCRAWL_VAULT_ID", runVaultId };
244
+ const companion = resolveCompanionVault(env, readFile);
245
+ if (companion) {
246
+ return { vaultId: companion.vaultId, source: `companion (${companion.vaultRepoPath})`, runVaultId };
247
+ }
248
+ return { vaultId: runVaultId, source: "run vault (no Companion settings found)", runVaultId };
249
+ }
250
+
251
+ /** Resolved once per process: the vault every request of this run names. */
252
+ let jobVaultMemo = null;
253
+ function jobVault() {
254
+ jobVaultMemo ??= resolveJobVault();
255
+ return jobVaultMemo;
256
+ }
257
+
258
+ /** One stderr line naming the vault the job was queued under — stdout stays pure for --json. */
259
+ function announceJobVault(posted) {
260
+ const v = jobVault();
261
+ const mismatch = v.runVaultId && v.vaultId && v.runVaultId !== v.vaultId ? ` (run vault ${v.runVaultId})` : "";
262
+ console.error(`spexcrawl: job ${posted.jobId} queued under vault ${v.vaultId || "(none)"} via ${v.source}${mismatch}`);
263
+ if (posted.companion?.likelyOffline) {
264
+ console.error(
265
+ `spexcrawl: WARNING no Companion has polled vault ${v.vaultId || "(none)"} recently ` +
266
+ `(last seen ${posted.companion.lastSeenAt || "never"}) — this job will likely sit pending until --timeout`,
267
+ );
268
+ }
269
+ }
270
+
271
+ function authHeaders(token) {
272
+ const vaultId = jobVault().vaultId;
273
+ return {
274
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
275
+ ...(vaultId ? { "x-heyspex-vault-id": vaultId } : {}),
276
+ };
277
+ }
278
+
279
+ // ── HTTP layer (every path returns a clean shape — never throws to the caller) ─────────────────
280
+ /** Extract the server's own words from a 400 body (JSON `error`/`message` or raw text). */
281
+ async function readErrorMessage(res) {
282
+ try {
283
+ const text = await res.text();
284
+ try {
285
+ const j = JSON.parse(text);
286
+ return String(j.error || j.message || text);
287
+ } catch {
288
+ return text || `HTTP ${res.status}`;
289
+ }
290
+ } catch {
291
+ return `HTTP ${res.status}`;
292
+ }
293
+ }
294
+
295
+ async function postJob(body, { baseUrl, token, httpFetch }) {
296
+ let res;
297
+ try {
298
+ res = await httpFetch(`${baseUrl}/api/agent/gather-jobs`, {
299
+ method: "POST",
300
+ headers: { "content-type": "application/json", ...authHeaders(token) },
301
+ body: JSON.stringify(body),
302
+ });
303
+ } catch (e) {
304
+ return { ok: false, unavailable: true, reason: `console unreachable at ${baseUrl}: ${e?.message || e}` };
305
+ }
306
+ if (res.status === 400) {
307
+ return { ok: false, badRequest: true, message: await readErrorMessage(res) };
308
+ }
309
+ if (res.status === 401 || res.status === 403) {
310
+ // An auth refusal is a verdict on the TOKEN, not on whether the Companion is running —
311
+ // `renderFailure` swaps the Companion hint for the re-pair hint on this flag.
312
+ return {
313
+ ok: false,
314
+ unavailable: true,
315
+ authRefused: true,
316
+ reason: `POST /api/agent/gather-jobs -> HTTP ${res.status} (${await readErrorMessage(res)})`,
317
+ };
318
+ }
319
+ if (res.status === 503) {
320
+ // The console REFUSES (does not queue) a job when no Companion has polled the job's vault
321
+ // recently ([[HS-L4LC]] fix direction 2) — typed `companion_not_listening`. Fail fast with a
322
+ // vault-naming hint instead of the generic tray-app one; any other 503 stays generic.
323
+ let j = null;
324
+ try {
325
+ j = await res.json();
326
+ } catch {
327
+ j = null;
328
+ }
329
+ if (j?.error === "companion_not_listening") {
330
+ return {
331
+ ok: false,
332
+ unavailable: true,
333
+ companionNotListening: true,
334
+ vaultId: j.vaultId || jobVault().vaultId || null,
335
+ lastSeenAt: j.companion?.lastSeenAt ?? null,
336
+ reason: `POST /api/agent/gather-jobs -> HTTP 503 companion_not_listening (${j.message || "no Companion polls this vault"})`,
337
+ };
338
+ }
339
+ return { ok: false, unavailable: true, reason: `POST /api/agent/gather-jobs -> HTTP 503` };
340
+ }
341
+ if (!res.ok) {
342
+ return { ok: false, unavailable: true, reason: `POST /api/agent/gather-jobs -> HTTP ${res.status}` };
343
+ }
344
+ let parsed;
345
+ try {
346
+ parsed = await res.json();
347
+ } catch (e) {
348
+ return { ok: false, unavailable: true, reason: `POST /api/agent/gather-jobs returned invalid JSON: ${e?.message || e}` };
349
+ }
350
+ const jobId = parsed?.jobId;
351
+ if (!jobId) {
352
+ return { ok: false, unavailable: true, reason: "POST /api/agent/gather-jobs returned no jobId" };
353
+ }
354
+ return {
355
+ ok: true,
356
+ jobId,
357
+ intentId: parsed.intentId,
358
+ // Console-reported relay presence for the job's vault ([[HS-L4LC]]); absent on older consoles.
359
+ ...(parsed.companion && typeof parsed.companion === "object" ? { companion: parsed.companion } : {}),
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Poll until the Companion has uploaded the pages (status "complete") or the deadline passes.
365
+ * The deadline path is the EXPECTED failure when the relay prerequisite is unmet (loopback
366
+ * console / Companion not running) — hence the LAN hint on the resulting unavailable line.
367
+ *
368
+ * TRANSIENT TOLERANCE ([[HS-FBZ3-NWVU-DIAG-spexcrawl-companion-connectivity]]): once the job is
369
+ * FILED, the Companion executes it whether or not this process can reach the console at that
370
+ * instant. A single hiccup on the read leg — the dev console self-restarting on its memory
371
+ * threshold under concurrent /hs-res load, a 4s-timeout reachability gap, a 5xx — therefore says
372
+ * nothing about the job. Measured live 2026-09-12: two pollers both took `GET
373
+ * /api/agent/gather-jobs/<id> 401` across one Next dev restart and gave up, while the Companion
374
+ * uploaded both jobs' pages seconds later. So up to {@link MAX_TRANSIENT_POLL_FAILURES}
375
+ * CONSECUTIVE transient read failures are retried at the normal poll cadence instead of ending
376
+ * the run. Nothing about the failure contract changes: a 400 still exits 1 immediately, and a
377
+ * streak longer than the bound (or the deadline, whichever comes first) still returns
378
+ * `unavailable` carrying the last real reason.
379
+ */
380
+ export async function pollJob(jobId, { baseUrl, token, httpFetch, timeoutMs = DEFAULT_TIMEOUT_MS, pollIntervalMs = POLL_INTERVAL_MS, sleepFn = (ms) => new Promise((r) => setTimeout(r, ms)), nowFn = Date.now } = {}) {
381
+ const started = nowFn();
382
+ /** Consecutive transient read failures; reset by any answered poll. */
383
+ let transientStreak = 0;
384
+ const deadlinePassed = () => nowFn() - started >= timeoutMs;
385
+
386
+ /** Retry a transient read at the poll cadence, or give up with its reason. */
387
+ const tolerate = async (reason) => {
388
+ transientStreak += 1;
389
+ if (transientStreak > MAX_TRANSIENT_POLL_FAILURES || deadlinePassed()) {
390
+ return {
391
+ ok: false,
392
+ unavailable: true,
393
+ reason: `${reason} (${transientStreak} consecutive poll failure(s))`,
394
+ };
395
+ }
396
+ await sleepFn(pollIntervalMs);
397
+ return null; // keep polling
398
+ };
399
+
400
+ for (;;) {
401
+ let res;
402
+ try {
403
+ res = await httpFetch(`${baseUrl}/api/agent/gather-jobs/${encodeURIComponent(jobId)}`, {
404
+ headers: authHeaders(token),
405
+ });
406
+ } catch (e) {
407
+ const giveUp = await tolerate(`console unreachable at ${baseUrl} while polling job ${jobId}: ${e?.message || e}`);
408
+ if (giveUp) return giveUp;
409
+ continue;
410
+ }
411
+ if (res.status === 400) {
412
+ return { ok: false, badRequest: true, message: await readErrorMessage(res) };
413
+ }
414
+ if (!res.ok) {
415
+ const giveUp = await tolerate(`GET /api/agent/gather-jobs/${jobId} -> HTTP ${res.status}`);
416
+ if (giveUp) return giveUp;
417
+ continue;
418
+ }
419
+ let parsed;
420
+ try {
421
+ parsed = await res.json();
422
+ } catch (e) {
423
+ const giveUp = await tolerate(`GET /api/agent/gather-jobs/${jobId} returned invalid JSON: ${e?.message || e}`);
424
+ if (giveUp) return giveUp;
425
+ continue;
426
+ }
427
+ transientStreak = 0;
428
+ if (parsed?.status === "complete") {
429
+ return {
430
+ ok: true,
431
+ jobId,
432
+ pages: Array.isArray(parsed.pages) ? parsed.pages : [],
433
+ // A research job's engine-level search account (which engines were
434
+ // tried, which answered, why the others did not) — present only when
435
+ // the Companion uploaded one. This is what makes a zero-page search
436
+ // explainable instead of silent (HS-RIZN).
437
+ ...(parsed.search !== undefined && parsed.search !== null ? { search: parsed.search } : {}),
438
+ };
439
+ }
440
+ if (deadlinePassed()) {
441
+ return {
442
+ ok: false,
443
+ unavailable: true,
444
+ reason: `job ${jobId} still "${parsed?.status || "pending"}" after ${Math.round(timeoutMs / 1000)}s — the Companion never uploaded results (job queued under vault ${jobVault().vaultId || "(none)"} via ${jobVault().source}; a Companion only claims jobs for the ONE vault its vaultRepoPath names — set HEYSPEX_SPEXCRAWL_VAULT_ID to that vault if they differ)`,
445
+ };
446
+ }
447
+ await sleepFn(pollIntervalMs);
448
+ }
449
+ }
450
+
451
+ // ── subcommand implementations (testable independent of the CLI/exit layer) ────────────────────
452
+ /**
453
+ * `search`: ONE round trip covers the skill's search+scrape steps together — the Companion
454
+ * searches locally and gathers the top results in the same pass, so the completed job's pages
455
+ * are the CLEANED MARKDOWN of the top hits, not bare links.
456
+ */
457
+ export async function runSearch(question, { httpFetch = (...a) => globalThis.fetch(...a), baseUrl = resolveConsoleUrl(), token = resolveConsoleToken(), timeoutMs = DEFAULT_TIMEOUT_MS, pollIntervalMs = POLL_INTERVAL_MS, sleepFn, nowFn } = {}) {
458
+ const posted = await postJob({ kind: "research", question }, { baseUrl, token, httpFetch });
459
+ if (!posted.ok) return posted;
460
+ announceJobVault(posted);
461
+ return pollJob(posted.jobId, { baseUrl, token, httpFetch, timeoutMs, pollIntervalMs, sleepFn, nowFn });
462
+ }
463
+
464
+ /** `scrape`/`gather`: fetch+clean the given URL(s) through the Companion. */
465
+ export async function runGather(urls, { httpFetch = (...a) => globalThis.fetch(...a), baseUrl = resolveConsoleUrl(), token = resolveConsoleToken(), timeoutMs = DEFAULT_TIMEOUT_MS, pollIntervalMs = POLL_INTERVAL_MS, sleepFn, nowFn } = {}) {
466
+ const posted = await postJob({ kind: "gather", urls }, { baseUrl, token, httpFetch });
467
+ if (!posted.ok) return posted;
468
+ announceJobVault(posted);
469
+ return pollJob(posted.jobId, { baseUrl, token, httpFetch, timeoutMs, pollIntervalMs, sleepFn, nowFn });
470
+ }
471
+
472
+ /** `crawl`: walk a whole site (sitemap + same-origin links) through the Companion.
473
+ * `maxPages`/`maxDepth` are sent only when they are positive numbers — omitted
474
+ * entirely otherwise (never `null`, never `0`), matching the console route's
475
+ * validation and `emitCrawlIntent`'s envelope contract. */
476
+ export async function runCrawl(site, { httpFetch = (...a) => globalThis.fetch(...a), baseUrl = resolveConsoleUrl(), token = resolveConsoleToken(), timeoutMs = DEFAULT_CRAWL_TIMEOUT_MS, pollIntervalMs = POLL_INTERVAL_MS, sleepFn, nowFn, maxPages, maxDepth } = {}) {
477
+ const body = { kind: "crawl", site };
478
+ if (typeof maxPages === "number" && maxPages > 0) body.max_pages = maxPages;
479
+ if (typeof maxDepth === "number" && maxDepth > 0) body.max_depth = maxDepth;
480
+ const posted = await postJob(body, { baseUrl, token, httpFetch });
481
+ if (!posted.ok) return posted;
482
+ announceJobVault(posted);
483
+ return pollJob(posted.jobId, { baseUrl, token, httpFetch, timeoutMs, pollIntervalMs, sleepFn, nowFn });
484
+ }
485
+
486
+ // ── output formatting + the failure contract (exact lines, exact codes) ────────────────────────
487
+ /** @returns {{code:number, line:string}} what the CLI prints and the exit code it sets. */
488
+ export function renderFailure(result) {
489
+ if (result.badRequest) {
490
+ return { code: 1, line: String(result.message || "bad request") };
491
+ }
492
+ const hint = result.authRefused
493
+ ? AUTH_HINT
494
+ : result.companionNotListening
495
+ ? companionVaultHint(result.vaultId)
496
+ : LAN_HINT;
497
+ return { code: 3, line: `SPEXCRAWL_UNAVAILABLE: ${result.reason} — ${hint}` };
498
+ }
499
+
500
+ /**
501
+ * Render a research job's search report: the one-line summary, then one row per
502
+ * engine — answered engines with their result count, failed engines with the
503
+ * engine's own reason. Empty string when the job carried no report (gather /
504
+ * crawl jobs, or a Companion built before the report existed).
505
+ */
506
+ export function formatSearchReportText(search) {
507
+ if (!search || typeof search !== "object") return "";
508
+ const lines = [`── search: ${search.summary || "(no summary)"}`];
509
+ for (const e of Array.isArray(search.engines) ? search.engines : []) {
510
+ if (e.answered) {
511
+ lines.push(` ${e.engine}: answered, ${e.results} result(s)`);
512
+ } else {
513
+ const status = e.httpStatus ? ` [HTTP ${e.httpStatus}]` : "";
514
+ lines.push(` ${e.engine}: no answer — ${e.reason || "(no reason given)"}${status}`);
515
+ }
516
+ }
517
+ return lines.join("\n");
518
+ }
519
+
520
+ /**
521
+ * The machine-readable failure tag for a page that was not cleaned, e.g.
522
+ * " [rate_limited, retry after 44s]". The Companion stamps `failureCode`
523
+ * (today only `rate_limited`: a request budget, ours or GitHub's, said no) and
524
+ * `retryAfterSeconds` when the wait is knowable (HS-L4LC). Empty for a page
525
+ * without a code, so older results print exactly as before. In --json both
526
+ * fields ride on each page verbatim.
527
+ */
528
+ export function formatFailureTag(p) {
529
+ if (!p || typeof p.failureCode !== "string" || !p.failureCode) return "";
530
+ const wait =
531
+ Number.isFinite(p.retryAfterSeconds) && p.retryAfterSeconds > 0 ? `, retry after ${p.retryAfterSeconds}s` : "";
532
+ return ` [${p.failureCode}${wait}]`;
533
+ }
534
+
535
+ export function formatPagesText(pages) {
536
+ if (!pages.length) return "(no pages)";
537
+ return pages
538
+ .map((p, i) => {
539
+ const state = p.state ? ` [${p.state}]` : "";
540
+ const lines = [`── ${i + 1}. ${p.url || "(no url)"}${state}`];
541
+ if (p.finalUrl && p.finalUrl !== p.url) lines.push(` finalUrl: ${p.finalUrl}`);
542
+ // The crawl identity this page was actually fetched under (preset id +
543
+ // robots.txt group token) — absent on pages fetched before the
544
+ // Companion stamped provenance, or when no identity was ever chosen.
545
+ if (p.identity && p.identity.presetId) {
546
+ lines.push(` identity: ${p.identity.presetId} (robots: ${p.identity.robotsToken})`);
547
+ }
548
+ if (p.markdown) lines.push("", p.markdown);
549
+ else if (p.reason) lines.push(` (no content: ${p.reason})${formatFailureTag(p)}`);
550
+ return lines.join("\n");
551
+ })
552
+ .join("\n\n");
553
+ }
554
+
555
+ /** `crawl`'s text rows: {title, url, markdown} per walked page — the shape a
556
+ * whole-site walk is expected to print (vs. `formatPagesText`'s state/finalUrl
557
+ * detail, which suits a known-address gather/scrape). */
558
+ export function formatCrawlPagesText(pages) {
559
+ if (!pages.length) return "(no pages)";
560
+ return pages
561
+ .map((p, i) => {
562
+ const lines = [`── ${i + 1}. ${p.title || "(no title)"}`, ` ${p.url || "(no url)"}`];
563
+ if (p.markdown) lines.push("", p.markdown);
564
+ else if (p.reason) lines.push(` (no content: ${p.reason})${formatFailureTag(p)}`);
565
+ return lines.join("\n");
566
+ })
567
+ .join("\n\n");
568
+ }
569
+
570
+ // ── CLI entry ──────────────────────────────────────────────────────────────────────────────────
571
+ const HELP = `usage: spexcrawl-cli.mjs search "<query>" [--json] [--timeout SECONDS]
572
+ spexcrawl-cli.mjs scrape "<url>" [<url>...] [--json] [--timeout SECONDS]
573
+ (gather is an alias of scrape)
574
+ spexcrawl-cli.mjs crawl "<site>" [--max-pages N] [--max-depth N] [--json] [--timeout SECONDS]
575
+
576
+ Runs SpexCrawl jobs through the HeySpex Companion via the spex-console: the job
577
+ is queued as a launch intent, the user's Companion app searches/fetches/cleans
578
+ on the local machine, and this CLI polls until the pages are uploaded.
579
+
580
+ search NOTE: results are the CLEANED PAGES (markdown) of the top hits — the
581
+ Companion searches and gathers in one pass, so one search call covers a research
582
+ skill's search+scrape steps together.
583
+
584
+ crawl NOTE: walks a whole site (sitemap + same-origin links) instead of fetching
585
+ known addresses — prints {title, url, markdown} rows. Its default --timeout is
586
+ longer (${DEFAULT_CRAWL_TIMEOUT_MS / 1000}) than search/scrape/gather's ${DEFAULT_TIMEOUT_MS / 1000}, since a walk is
587
+ bigger than a scrape. --max-pages/--max-depth are omitted from the request
588
+ entirely when not given (never sent as 0).
589
+
590
+ --timeout SECONDS give up polling after this long (default ${DEFAULT_TIMEOUT_MS / 1000}; crawl default ${DEFAULT_CRAWL_TIMEOUT_MS / 1000})
591
+ --json print { "jobId", "pages": [{ url, state, markdown, ... }],
592
+ "search"? } — search jobs also carry the engine-level report
593
+ { query, enginesAttempted, enginesAnswered, resultsFound,
594
+ resultsGathered, summary, engines: [...] }, so a zero-page
595
+ search says which engines were tried, which answered, and
596
+ why the others did not. A page that was not cleaned may
597
+ carry failureCode ("rate_limited") + retryAfterSeconds, so
598
+ a caller can back off without parsing "reason"
599
+ --max-pages N crawl only: cap the number of pages walked (positive integer)
600
+ --max-depth N crawl only: cap the link depth walked (positive integer)
601
+
602
+ Environment:
603
+ HEYSPEX_CONSOLE_URL console base URL (default ${DEFAULT_CONSOLE_URL})
604
+ HEYSPEX_COMPANION_TOKEN bearer token override; default reads
605
+ clerk.companion_token from ~/.heyspex/credentials.yaml
606
+ (no auth header is sent when neither resolves)
607
+
608
+ Prerequisite: ${LAN_HINT}.
609
+
610
+ Exit codes: 0 ok · 1 bad request (server message printed) · 3 SPEXCRAWL_UNAVAILABLE
611
+ (console unreachable / non-2xx / poll timeout) · 64 usage error`;
612
+
613
+ function printUsage() {
614
+ console.error('usage: spexcrawl-cli.mjs search "<query>" [--json] [--timeout SECONDS]');
615
+ console.error(' spexcrawl-cli.mjs scrape "<url>" [<url>...] [--json] [--timeout SECONDS] (alias: gather)');
616
+ console.error(' spexcrawl-cli.mjs crawl "<site>" [--max-pages N] [--max-depth N] [--json] [--timeout SECONDS]');
617
+ console.error(" spexcrawl-cli.mjs --help");
618
+ }
619
+
620
+ /**
621
+ * Set the exit code and RETURN — never call process.exit() after doing network work.
622
+ * (Same rule as firecrawl-cli.mjs: forcing an exit with live HTTP handles aborts the process on
623
+ * Windows with a libuv assertion AFTER stdout flushed — perfect JSON alongside a non-zero exit,
624
+ * which poisons any caller that checks exit status. Letting the loop drain exits cleanly.)
625
+ */
626
+ function finish(code) {
627
+ process.exitCode = code;
628
+ }
629
+
630
+ function exitOnResult(result, { json, formatText = formatPagesText }) {
631
+ if (!result.ok) {
632
+ const { code, line } = renderFailure(result);
633
+ console.log(line);
634
+ return finish(code);
635
+ }
636
+ if (json) {
637
+ console.log(JSON.stringify({
638
+ jobId: result.jobId,
639
+ pages: result.pages,
640
+ ...(result.search !== undefined ? { search: result.search } : {}),
641
+ }));
642
+ return finish(0);
643
+ }
644
+ // The search report leads: it is short, and on a zero-page result it IS the
645
+ // answer — which engines were tried, which answered, why the others did not.
646
+ const searchText = result.search !== undefined ? formatSearchReportText(result.search) : "";
647
+ console.log([searchText, formatText(result.pages)].filter(Boolean).join("\n\n"));
648
+ return finish(0);
649
+ }
650
+
651
+ /** Flags that take a value — used to strip both the flag and its value out of
652
+ * the positional-args list, and (via indexOf) to parse the value itself. */
653
+ const VALUE_FLAGS = ["--timeout", "--max-pages", "--max-depth"];
654
+
655
+ async function main() {
656
+ const argv = process.argv.slice(2);
657
+ if (argv.includes("--help") || argv.includes("-h")) {
658
+ console.log(HELP);
659
+ process.exit(0);
660
+ return;
661
+ }
662
+
663
+ const cmd = argv[0];
664
+ const json = argv.includes("--json");
665
+
666
+ if (cmd !== "search" && cmd !== "scrape" && cmd !== "gather" && cmd !== "crawl") {
667
+ printUsage();
668
+ process.exit(64);
669
+ return;
670
+ }
671
+
672
+ const timeoutIdx = argv.indexOf("--timeout");
673
+ const timeoutSec = timeoutIdx !== -1 ? Number(argv[timeoutIdx + 1]) : NaN;
674
+ const defaultTimeoutMs = cmd === "crawl" ? DEFAULT_CRAWL_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
675
+ const timeoutMs = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec * 1000 : defaultTimeoutMs;
676
+
677
+ // positional args after the verb, minus --json and every value-flag + its value
678
+ const targets = argv
679
+ .slice(1)
680
+ .filter((a, i, arr) => a !== "--json" && !VALUE_FLAGS.includes(a) && !VALUE_FLAGS.includes(arr[i - 1]));
681
+
682
+ if (!targets.length) {
683
+ console.error(`usage error: missing ${cmd === "search" ? "<query>" : cmd === "crawl" ? "<site>" : "<url>"} argument`);
684
+ printUsage();
685
+ process.exit(64);
686
+ return;
687
+ }
688
+
689
+ let maxPages;
690
+ let maxDepth;
691
+ if (cmd === "crawl") {
692
+ const maxPagesIdx = argv.indexOf("--max-pages");
693
+ if (maxPagesIdx !== -1) {
694
+ maxPages = Number(argv[maxPagesIdx + 1]);
695
+ if (!Number.isInteger(maxPages) || maxPages <= 0) {
696
+ console.error(`usage error: --max-pages must be a positive integer, got ${JSON.stringify(argv[maxPagesIdx + 1])}`);
697
+ printUsage();
698
+ process.exit(64);
699
+ return;
700
+ }
701
+ }
702
+ const maxDepthIdx = argv.indexOf("--max-depth");
703
+ if (maxDepthIdx !== -1) {
704
+ maxDepth = Number(argv[maxDepthIdx + 1]);
705
+ if (!Number.isInteger(maxDepth) || maxDepth <= 0) {
706
+ console.error(`usage error: --max-depth must be a positive integer, got ${JSON.stringify(argv[maxDepthIdx + 1])}`);
707
+ printUsage();
708
+ process.exit(64);
709
+ return;
710
+ }
711
+ }
712
+ }
713
+
714
+ try {
715
+ const result =
716
+ cmd === "search"
717
+ ? await runSearch(targets.join(" "), { timeoutMs })
718
+ : cmd === "crawl"
719
+ ? await runCrawl(targets[0], { timeoutMs, maxPages, maxDepth })
720
+ : await runGather(targets, { timeoutMs });
721
+ exitOnResult(result, { json, formatText: cmd === "crawl" ? formatCrawlPagesText : formatPagesText });
722
+ } catch (e) {
723
+ // Last-resort catch — every internal path above already returns a clean shape, but this
724
+ // guarantees the CLI NEVER surfaces a raw stack trace, per the shared failure contract.
725
+ console.log(`SPEXCRAWL_UNAVAILABLE: unexpected error — ${e?.message || String(e)} — ${LAN_HINT}`);
726
+ return finish(3);
727
+ }
728
+ }
729
+
730
+ const isMain = import.meta.url.endsWith("/" + (process.argv[1]?.split(/[\\/]/).pop() ?? ""));
731
+
732
+ if (isMain) {
733
+ main();
734
+ }