heyspex 0.2.6 → 0.2.7

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 (85) hide show
  1. package/bin/hs.mjs +85 -25
  2. package/bin/spexcode.mjs +31 -0
  3. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  4. package/dev-factory/lib/seat-context.mjs +20 -4
  5. package/dev-factory/model-catalog.json +222 -222
  6. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  7. package/dist/cli/hs-connect.mjs +20 -2
  8. package/dist/cli/hs-defer.mjs +19 -1
  9. package/dist/cli/hs-index-rebuild.mjs +19 -1
  10. package/dist/cli/hs-index-reconcile.mjs +19 -1
  11. package/dist/cli/hs-init.mjs +2 -2
  12. package/dist/cli/hs-install.mjs +2 -2
  13. package/dist/cli/hs-join.mjs +20 -2
  14. package/dist/cli/hs-next-launch.mjs +89 -10
  15. package/dist/cli/hs-spawn.mjs +27 -1
  16. package/dist/cli/hs-vault-pull.mjs +19 -1
  17. package/dist/cli/hs-vault-push.mjs +19 -1
  18. package/dist/cli/hs-vault.mjs +21 -7
  19. package/dist/cli/hs-workspace.mjs +19 -1
  20. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  21. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  22. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  23. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  24. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  25. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  26. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  27. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  28. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  29. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  30. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  31. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  32. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  33. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  34. package/dist/cli-templates/harness-commands/claude/hs-res.md +20 -8
  35. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  36. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  37. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  38. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  39. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  40. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  41. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  42. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  43. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  44. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  45. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  46. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  47. package/dist/cli-templates/harness-commands/cursor/hs-res.md +20 -8
  48. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  49. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  50. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  51. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  52. package/docs/copilot-plugin-install.md +7 -0
  53. package/docs/mcp-client-config.md +6 -0
  54. package/package.json +7 -2
  55. package/plugins/heyspex/README.md +6 -0
  56. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  57. package/plugins/heyspex/hooks/hooks.json +418 -418
  58. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  59. package/scripts/hs-companion.mjs +38 -8
  60. package/scripts/lib/companion-install.mjs +768 -0
  61. package/scripts/lib/customer-kit.mjs +5 -3
  62. package/scripts/lib/hs-companion-settings.mjs +1 -0
  63. package/scripts/lib/hs-source-push.mjs +386 -386
  64. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  65. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  66. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  67. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  68. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  69. package/spexharness/src/seat-context.mjs +10 -5
  70. package/spexharness/src/session-bootstrap.mjs +6 -3
  71. package/vendor/heyspex-mcp/cli.mjs +6 -1
  72. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  73. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  74. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  75. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  76. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  77. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  78. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  79. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  80. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  81. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  82. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  83. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  84. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  85. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
package/bin/hs.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { BINDING_VERBS, runVaultBindingCommand } from "./hs-vault-binding.mjs";
15
15
  import { findProjectRoot } from "../scripts/lib/customer-kit.mjs";
16
+ import { companionDoctorRow } from "../scripts/lib/companion-install.mjs";
16
17
  import { spawn } from "node:child_process";
17
18
  import { existsSync, readFileSync, realpathSync } from "node:fs";
18
19
  import path from "node:path";
@@ -86,15 +87,19 @@ function helpForThisInstall(lines) {
86
87
  if (!IS_INSTALLED_PACKAGE) return lines;
87
88
  const out = [];
88
89
  for (const line of lines) {
90
+ if (line.startsWith(" hs seat set|reset|assignments")) {
91
+ out.push(" hs seat list|set|reset [--harness <h>] Your own model per SpexFleet seat in this vault (Claude Code / Copilot)");
92
+ continue;
93
+ }
89
94
  if (DEV_ONLY_HELP_PREFIXES.some((p) => line.startsWith(p))) continue;
90
95
  if (line.startsWith(" hs companion start|stop|status|build")) {
91
- out.push(" hs companion start|stop|status Desktop companion tray app (not in the npm package yet)");
96
+ out.push(" hs companion start|stop|status Desktop companion tray app (installed by `hs companion install`)");
92
97
  continue;
93
98
  }
94
99
  out.push(line);
95
100
  }
96
101
  out.push(
97
- "HeySpex developer-only verbs (hs server, hs factory, hs seat, hs admin, hs queue,",
102
+ "HeySpex developer-only verbs (hs server, hs factory, hs seat add|update|disable, hs admin, hs queue,",
98
103
  "hs doctrine, hs companion build) need a HeySpex source checkout and are not",
99
104
  "listed here. Neither are verbs whose implementation the npm package does not",
100
105
  "carry (hs agent, hs chat, hs code-watcher, hs harness, hs hooks, hs import,",
@@ -678,13 +683,12 @@ function usageLines() {
678
683
  " hs worktree list|alloc|release|reap [--json] [--dry-run] Session/build worktree registry (HS-03DO)",
679
684
  " hs view <surface> [spec-id] [--width N --height N --browser edge|chrome] Open one surface in a chromeless app-mode window",
680
685
  " hs server stop [--json] Stop the spex-console dev server (any terminal)",
681
- " hs source status|preflight|commit|push SpexSource local git operations",
682
- " hs source identity [show|set|clear] Git author identity (set/clear are human-only; see hs source --help)",
683
686
  " hs defer <id> [--until <when>] [--json] Time-shift a spec (defer_until)",
684
687
  " hs undefer <id> [--json] Clear a spec's defer_until",
685
688
  " hs spawn [<id>] [--dry-run] [--json] Launch a fresh /hs-next agent in a new terminal",
686
689
  ' hs capture "<text>"|--clipboard|--file <p> [--add] Capture → console drafts (--add chains a group)',
687
690
  " hs companion start|stop|status|build Desktop companion tray app",
691
+ " hs companion install [--quiet] [--dry-run] Download, verify and run the signed SpexCompanion setup (Windows x64)",
688
692
  " hs companion settings [list|get <k>|set <k> <v>] [--json] Every Companion setting (console-backed)",
689
693
  " hs companion mute|unmute|volume|orb|wake|activation|hotkey [<v>] Setting shortcuts (omit <v> to read)",
690
694
  " hs registry list [--json] List launch registry entries",
@@ -702,6 +706,7 @@ function usageLines() {
702
706
  " hs next assist|auto <milestone...> --launch Arm a dispatcher run on one or more milestones",
703
707
  " hs model [<seat>] [--launch|--fleet] Every seat and the model set for it",
704
708
  " hs seat list|add|update|disable [--json] Read/add/update/disable a seat in the registry (dev-factory/seats.json)",
709
+ " hs seat set|reset|assignments [--harness] Your own model per SpexFleet seat, this vault (customers: hs seat list|set|reset)",
705
710
  " hs voice add|build|show|set|test|refresh|export Build and manage a coded voice profile",
706
711
  " hs trash [list|restore|empty] The bin spec deletes go into",
707
712
  " hs auto [filename] [--json] Resolve /hs-auto dispatch plan",
@@ -731,6 +736,9 @@ function usageLines() {
731
736
  " search/scrape/crawl CLI (standalone script,",
732
737
  " not an `hs` verb; also reachable from the",
733
738
  " Companion)",
739
+ " node dev-factory/spexcrawl/firecrawl-cli.mjs search|scrape Firecrawl — finds the",
740
+ " self-hosted Firecrawl in this machine's",
741
+ " Docker (no env var, no API key)",
734
742
  "",
735
743
  "Control plane (Neon — requires a reachable console):",
736
744
  " hs pin get|set <id>|clear Read/set/clear the on-deck pin",
@@ -916,6 +924,11 @@ function runStandaloneDoctor(args, vaultRoot) {
916
924
  if (templateRow.status === "fail") report.ok = false;
917
925
  report.checks.push(templateRow);
918
926
 
927
+ // `companion` (HS-056P): is SpexCompanion installed? Optional, so it is never
928
+ // a failure -- missing on Windows is a warn that offers
929
+ // `npx heyspex companion install`; other platforms get a skipped note.
930
+ report.checks.push(companionDoctorRow());
931
+
919
932
  // `connection` ([[HS-W73V-WW9R-TASK-make-both-doctor-shapes-fail-on-cloud]],
920
933
  // invariants 8 and 10): the BUNDLED `hs connect --check --json` from the
921
934
  // invoked cwd, folded by the SAME function the full doctor uses
@@ -1096,8 +1109,22 @@ export const NOT_IN_PACKAGE_VERBS = Object.freeze({
1096
1109
  factory: "SpexFleet/dev-factory internals are platform-only.",
1097
1110
  doctrine: "SpexCode Doctrine synthesis internals are platform-only.",
1098
1111
  bench: "SpexCode bench internals are platform-only.",
1112
+ server: "the spex-console dev server is part of the HeySpex source checkout; the hosted console is app.heyspex.com.",
1099
1113
  });
1100
1114
 
1115
+ /**
1116
+ * Is this `hs <verb> ... --help|-h` (a help flag after the verb, before any
1117
+ * `--` end-of-options marker)? Bare `hs --help` is handled before dispatch.
1118
+ * @param {string[]} argv the argv after the global --vault flag is removed
1119
+ */
1120
+ export function isHelpInvocation(argv) {
1121
+ for (const a of argv.slice(1)) {
1122
+ if (a === "--") return false;
1123
+ if (a === "--help" || a === "-h") return true;
1124
+ }
1125
+ return false;
1126
+ }
1127
+
1101
1128
  /**
1102
1129
  * The NOT_IN_PACKAGE_VERBS key this invocation hits from an installed
1103
1130
  * package, or null. Pure apart from `installed`.
@@ -1121,6 +1148,21 @@ export function notInPackageMessage(key) {
1121
1148
  );
1122
1149
  }
1123
1150
 
1151
+ /**
1152
+ * Does `hs seat <command>` go to the customer seat-assignment CLI
1153
+ * (vendor/heyspex-mcp/seat-assign-cli.mjs, epic HS-K9L7)? Installed package:
1154
+ * list|assignments|set|reset|defaults|report-fallback|help (and a bare `hs seat`).
1155
+ * Source checkout: the same minus `list`/bare, which stay the dev-factory
1156
+ * registry (`hs-seat.ts`).
1157
+ */
1158
+ export function routesToSeatAssignCli(namespace, command, { installed = IS_INSTALLED_PACKAGE } = {}) {
1159
+ if (namespace !== "seat") return false;
1160
+ if (installed) {
1161
+ return command === undefined || ["list", "assignments", "set", "reset", "defaults", "report-fallback", "help", "--help", "-h"].includes(command);
1162
+ }
1163
+ return ["assignments", "set", "reset", "defaults", "report-fallback"].includes(command);
1164
+ }
1165
+
1124
1166
  function main(argv) {
1125
1167
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
1126
1168
  usage();
@@ -1152,6 +1194,16 @@ function main(argv) {
1152
1194
  const command = argv[1];
1153
1195
  const rest = argv.slice(2);
1154
1196
 
1197
+ // `hs seat list|set|reset|defaults|report-fallback` — the signed-in user's OWN
1198
+ // seat models (HS-K9L7). Installed package: these subcommands. Source checkout:
1199
+ // set|reset|defaults|assignments|report-fallback (list stays the registry).
1200
+ if (routesToSeatAssignCli(namespace, command)) {
1201
+ import(pathToFileURL(path.join(PKG_ROOT, "vendor", "heyspex-mcp", "seat-assign-cli.mjs")).href)
1202
+ .then((m) => m.runSeatAssignCli(argv.slice(1)))
1203
+ .then((code) => process.exit(code));
1204
+ return;
1205
+ }
1206
+
1155
1207
  // A source-checkout-only verb from an installed package: say so before vault
1156
1208
  // resolution can refuse with an unrelated binding error.
1157
1209
  const missingVerb = notInPackageVerb(namespace, command);
@@ -1170,31 +1222,38 @@ function main(argv) {
1170
1222
  }
1171
1223
  }
1172
1224
 
1173
- // `hs companion start|stop|status|build` from an installed package: the tray
1174
- // app's source (companion/) is not in the tarball, so say THAT first -- before
1175
- // vault resolution can refuse with "not bound to a vault", which sends a
1176
- // customer to fix a binding that would not help.
1225
+ // `hs companion start|stop|status|build` from an installed package (no
1226
+ // companion/ source in the tarball) and `hs companion install` anywhere are
1227
+ // machine-local and need no vault: run them BEFORE vault resolution can
1228
+ // refuse with "not bound to a vault", which sends a customer to fix a binding
1229
+ // that would not help. scripts/hs-companion.mjs drives an installed
1230
+ // SpexCompanion, or offers `npx heyspex companion install` when there is none
1231
+ // (HS-056P; this replaced the "not distributed through the npm package yet" answer).
1177
1232
  const companionSub = companionHostWithoutSource(namespace, command);
1178
- if (companionSub) {
1179
- import("../scripts/lib/hs-companion-settings.mjs").then(
1180
- (m) => {
1181
- process.stderr.write(`${m.renderCompanionNotDistributed(companionSub)}\n`);
1182
- process.exit(1);
1183
- },
1184
- (err) => {
1185
- process.stderr.write(`hs companion: ${err.message}\n`);
1186
- process.exit(1);
1187
- },
1188
- );
1233
+ if (companionSub || (namespace === "companion" && command === "install")) {
1234
+ const companionArgs = command ? [command, ...rest] : ["start", ...rest];
1235
+ const child = spawn(process.execPath, [spawnTargetPath("companion"), ...companionArgs], {
1236
+ cwd: INVOKED_CWD,
1237
+ stdio: "inherit",
1238
+ });
1239
+ child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
1240
+ child.on("error", (err) => {
1241
+ process.stderr.write(`hs companion: ${err.message}\n`);
1242
+ process.exit(1);
1243
+ });
1189
1244
  return;
1190
1245
  }
1191
1246
 
1192
- const {
1193
- vaultRoot,
1194
- usedRegistryFallback,
1195
- error: vaultError,
1196
- brokenPin,
1197
- } = resolveVaultRootForMain(process.cwd(), process.env);
1247
+ const resolved = resolveVaultRootForMain(process.cwd(), process.env);
1248
+ const { usedRegistryFallback, error: vaultError } = resolved;
1249
+ // `hs <verb> --help` must always print help and exit 0, bound vault or not
1250
+ // (install QC: ~28 verbs refused with VAULT_UNBOUND from an unbound folder).
1251
+ // Help never reads or writes a vault, so an unresolved vault falls back to the
1252
+ // invocation folder and the vault refusals below (broken pin, no vault,
1253
+ // uncloned satellite) are skipped; each verb's own handler prints its help.
1254
+ const helpRequested = isHelpInvocation(argv);
1255
+ const vaultRoot = resolved.vaultRoot || (helpRequested ? INVOKED_CWD : resolved.vaultRoot);
1256
+ const brokenPin = helpRequested ? null : resolved.brokenPin;
1198
1257
  // The registry pointer is never consulted ([[HS-TYJA]]), so the only P1
1199
1258
  // refusal left is a satellite pin that cannot be honoured; onboarding /
1200
1259
  // registry verbs and `hs doctor` still run so the pin can be repaired.
@@ -1588,6 +1647,7 @@ function main(argv) {
1588
1647
  // vault's files to disk must never scaffold specs/ into a code repo.
1589
1648
  if (
1590
1649
  ["pull", "push", "index"].includes(namespace) &&
1650
+ !helpRequested &&
1591
1651
  readMarkerSatellite(vaultRoot) &&
1592
1652
  !existsSync(path.join(vaultRoot, "specs"))
1593
1653
  ) {
package/bin/spexcode.mjs CHANGED
@@ -33,6 +33,8 @@ import { readFileSync, realpathSync } from "node:fs";
33
33
  import path from "node:path";
34
34
  import process from "node:process";
35
35
  import { fileURLToPath } from "node:url";
36
+ import { PRODUCT_SEATS, canonicalSeatId, defaultModelForSeat } from "../vendor/heyspex-mcp/seat-defaults.mjs";
37
+ import { detectHarness } from "../spexharness/src/adapters/harness-detect.mjs";
36
38
 
37
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
40
 
@@ -200,6 +202,29 @@ export function resolveSeatGuard(argv, seatsRegistry) {
200
202
  };
201
203
  }
202
204
 
205
+ /**
206
+ * HS-MDZB (epic HS-C65J): a customer install ships no dev-factory/seats.json (that file is the
207
+ * owner's own fleet). When it is ABSENT, a product seat id is allowed and runs on the harness
208
+ * default (Claude tiers under Claude Code, `auto` under Copilot); an unknown id is still refused.
209
+ * Pure: env in, a guard verdict plus a one-line notice out.
210
+ * @returns {{allowed: boolean, message?: string, notice?: string}}
211
+ */
212
+ export function resolveSeatGuardWithoutRegistry(seatId, env = {}) {
213
+ const id = canonicalSeatId(seatId);
214
+ const known = PRODUCT_SEATS.some((s) => s.id === id);
215
+ if (!known) {
216
+ return {
217
+ allowed: false,
218
+ message: `spexcode: refusing --seat "${seatId}" — not a HeySpex seat (known: ${PRODUCT_SEATS.map((s) => s.id).join(", ")}).`,
219
+ };
220
+ }
221
+ const d = defaultModelForSeat(id, detectHarness(env).harness);
222
+ const notice = d.model
223
+ ? `spexcode: seat "${id}" uses the ${d.harness} default model "${d.model}".`
224
+ : `spexcode: seat "${id}" has no default model (no harness detected; set HEYSPEX_HARNESS=claude-code or copilot).`;
225
+ return { allowed: true, notice };
226
+ }
227
+
203
228
  /** Load + JSON.parse `dev-factory/seats.json`. Isolated so tests can stub it. */
204
229
  export function loadSeatsRegistry(seatsJsonPath = SEATS_JSON_PATH) {
205
230
  return JSON.parse(readFileSync(seatsJsonPath, "utf8"));
@@ -273,6 +298,12 @@ export async function runSpexcode(argv, deps = {}) {
273
298
  try {
274
299
  registry = loadRegistry();
275
300
  } catch (err) {
301
+ if (err && err.code === "ENOENT") {
302
+ const guard = resolveSeatGuardWithoutRegistry(seatId, deps.env ?? process.env);
303
+ stderr.write((guard.allowed ? guard.notice : guard.message) + "\n");
304
+ if (!guard.allowed) return 2;
305
+ return runForward(argv, { hsPath, spawnFn });
306
+ }
276
307
  stderr.write(
277
308
  `spexcode: could not read dev-factory/seats.json to check --seat "${seatId}": ` +
278
309
  `${err && err.message ? err.message : err}\n`,
@@ -0,0 +1,294 @@
1
+ /**
2
+ * local-firecrawl.mjs — zero-config discovery of a self-hosted Firecrawl running in THIS machine's
3
+ * Docker, plus a minimal search/scrape client for it.
4
+ *
5
+ * Owner ruling 2026-10-17: research in a customer repo (the `heyspex` npm package) finds URLs
6
+ * through a self-hosted Firecrawl in the user's LOCAL Docker. "No ENV variable required. It will
7
+ * just see the Docker>Firecrawl on the local and it will utilize it." So this module never needs
8
+ * configuration:
9
+ *
10
+ * 1. optional override — HEYSPEX_FIRECRAWL_URL, then FIRECRAWL_BASE_URL (ignored when it points
11
+ * at the hosted api.firecrawl.dev: hosted credits are ask-before-spend, never automatic);
12
+ * 2. `docker ps` — the Firecrawl API container and the host port it publishes;
13
+ * 3. the self-hosted default, http://localhost:3002 (SELF_HOST.md in github.com/firecrawl/firecrawl).
14
+ *
15
+ * Every candidate is probed with a GET on its origin before use. Hosted Firecrawl is filtered out
16
+ * of every list, no API key is ever sent (a local stack runs with USE_DB_AUTHENTICATION=false),
17
+ * and nothing here throws: failures come back as `{ ok: false, reason }`.
18
+ *
19
+ * Node built-ins only — this file ships in the npm package and must not import SpexFleet code.
20
+ */
21
+ import { spawnSync } from 'node:child_process';
22
+
23
+ export const DEFAULT_LOCAL_FIRECRAWL_PORT = 3002;
24
+ export const DEFAULT_LOCAL_FIRECRAWL_BASE_URLS = Object.freeze([
25
+ `http://127.0.0.1:${DEFAULT_LOCAL_FIRECRAWL_PORT}/v2`,
26
+ `http://localhost:${DEFAULT_LOCAL_FIRECRAWL_PORT}/v2`,
27
+ ]);
28
+
29
+ /** One-line setup instructions printed after every FIRECRAWL_UNAVAILABLE reason. */
30
+ export const LOCAL_FIRECRAWL_SETUP_HINT =
31
+ 'Research needs a self-hosted Firecrawl running in your local Docker. Start Docker Desktop, then ' +
32
+ 'start the Firecrawl stack: `git clone https://github.com/firecrawl/firecrawl.git && cd firecrawl`, ' +
33
+ 'create `.env` in that folder with `PORT=3002` and `USE_DB_AUTHENTICATION=false`, then ' +
34
+ '`docker compose build && docker compose up -d` (official steps: SELF_HOST.md in ' +
35
+ 'github.com/firecrawl/firecrawl). No API key or environment variable is needed: it is found ' +
36
+ 'automatically on localhost:3002 or from `docker ps`.';
37
+
38
+ /** True for the hosted Firecrawl API (the thing that spends real credits). */
39
+ export function isHostedFirecrawlUrl(url) {
40
+ try {
41
+ const host = new URL(url).hostname.toLowerCase();
42
+ return host === 'firecrawl.dev' || host.endsWith('.firecrawl.dev');
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** Trim, drop trailing slashes, and add the `/v2` API path when the URL has no version path. */
49
+ export function normalizeFirecrawlBaseUrl(url) {
50
+ const raw = String(url ?? '').trim().replace(/\/+$/, '');
51
+ if (!raw) return '';
52
+ try {
53
+ const u = new URL(raw);
54
+ if (!/^https?:$/.test(u.protocol)) return '';
55
+ if (!/\/v\d+$/.test(u.pathname.replace(/\/+$/, ''))) u.pathname = `${u.pathname.replace(/\/+$/, '')}/v2`;
56
+ return u.toString().replace(/\/+$/, '');
57
+ } catch {
58
+ return '';
59
+ }
60
+ }
61
+
62
+ function originOf(baseUrl) {
63
+ try {
64
+ return new URL(baseUrl).origin;
65
+ } catch {
66
+ return '';
67
+ }
68
+ }
69
+
70
+ // ── docker ps ───────────────────────────────────────────────────────────────────────────────────
71
+
72
+ const NON_API_SERVICE_RE = /playwright|postgres|redis|rabbit|searxng|worker/i;
73
+
74
+ /** Is this `docker ps` row the Firecrawl API container (not its playwright/db/queue siblings)? */
75
+ export function isFirecrawlApiContainer({ name = '', image = '' } = {}) {
76
+ const imageRepo = String(image).split('@')[0];
77
+ const lastSeg = imageRepo.split('/').pop().split(':')[0].toLowerCase();
78
+ const n = String(name).toLowerCase();
79
+ if (NON_API_SERVICE_RE.test(lastSeg)) return false;
80
+ if (lastSeg === 'firecrawl' || lastSeg === 'firecrawl-api' || lastSeg === 'firecrawl_api') return true;
81
+ // `docker compose build` from a checkout tags the image after the project; the container name
82
+ // (firecrawl-api-1 / firecrawl_api_1) still says what it is.
83
+ return /firecrawl/.test(n) && /(^|[-_])api([-_]\d+)?$/.test(n) && !NON_API_SERVICE_RE.test(n);
84
+ }
85
+
86
+ /**
87
+ * Parse a `docker ps` Ports column, e.g. `0.0.0.0:3002->3002/tcp, [::]:3002->3002/tcp`, into
88
+ * published TCP mappings `{ hostIp, hostPort, containerPort }`. Unpublished ports (`6379/tcp`) are
89
+ * dropped. Ranges map their first port.
90
+ */
91
+ export function parseDockerPorts(portsText) {
92
+ const out = [];
93
+ for (const part of String(portsText ?? '').split(',')) {
94
+ const m = part.trim().match(/^(?:(\[[^\]]*\]|[^:\s]+):)?(\d+)(?:-\d+)?->(\d+)(?:-\d+)?\/tcp$/);
95
+ if (!m) continue;
96
+ out.push({ hostIp: m[1] ?? '', hostPort: Number(m[2]), containerPort: Number(m[3]) });
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function hostForBinding(hostIp) {
102
+ const ip = String(hostIp).replace(/^\[|\]$/g, '');
103
+ if (!ip || ip === '0.0.0.0' || ip === '127.0.0.1') return '127.0.0.1';
104
+ if (ip === '::' || ip === '::1') return '[::1]';
105
+ return ip.includes(':') ? `[${ip}]` : ip;
106
+ }
107
+
108
+ /**
109
+ * Parse `docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}'` output into the Firecrawl API
110
+ * containers it lists, each with the base URLs its published ports reach.
111
+ */
112
+ export function parseDockerPsFirecrawl(text) {
113
+ const rows = [];
114
+ for (const line of String(text ?? '').split(/\r?\n/)) {
115
+ if (!line.trim()) continue;
116
+ const [name = '', image = '', ports = ''] = line.split('\t');
117
+ if (!isFirecrawlApiContainer({ name, image })) continue;
118
+ const maps = parseDockerPorts(ports);
119
+ // The API listens on PORT (3002 by default) inside the container; prefer that mapping.
120
+ const preferred = maps.filter((p) => p.containerPort === DEFAULT_LOCAL_FIRECRAWL_PORT);
121
+ const ordered = [...preferred, ...maps.filter((p) => p.containerPort !== DEFAULT_LOCAL_FIRECRAWL_PORT)];
122
+ const seen = new Set();
123
+ const baseUrls = [];
124
+ for (const p of ordered) {
125
+ const u = `http://${hostForBinding(p.hostIp)}:${p.hostPort}/v2`;
126
+ if (seen.has(u)) continue;
127
+ seen.add(u);
128
+ baseUrls.push(u);
129
+ }
130
+ rows.push({ name: name.trim(), image: image.trim(), baseUrls });
131
+ }
132
+ return rows;
133
+ }
134
+
135
+ /** Default docker runner. Returns `{ status, stdout, stderr, error }` like spawnSync; never throws. */
136
+ export function runDockerPs() {
137
+ try {
138
+ const r = spawnSync('docker', ['ps', '--format', '{{.Names}}\t{{.Image}}\t{{.Ports}}'], {
139
+ encoding: 'utf8',
140
+ timeout: 8000,
141
+ windowsHide: true,
142
+ });
143
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '', error: r.error ?? null };
144
+ } catch (e) {
145
+ return { status: null, stdout: '', stderr: '', error: e };
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Ask Docker which Firecrawl API containers are running.
151
+ * @returns {{ state: 'missing'|'down'|'running', containers: Array<{name,image,baseUrls}>, detail?: string }}
152
+ */
153
+ export function dockerFirecrawlContainers({ runDocker = runDockerPs } = {}) {
154
+ let r;
155
+ try {
156
+ r = runDocker();
157
+ } catch (e) {
158
+ return { state: 'missing', containers: [], detail: e?.message || String(e) };
159
+ }
160
+ if (r?.error) {
161
+ const code = r.error.code;
162
+ return { state: code === 'ENOENT' ? 'missing' : 'down', containers: [], detail: r.error.message || String(code) };
163
+ }
164
+ if (r?.status !== 0) {
165
+ return { state: 'down', containers: [], detail: String(r?.stderr || '').trim().split(/\r?\n/)[0] || `docker ps exited ${r?.status}` };
166
+ }
167
+ return { state: 'running', containers: parseDockerPsFirecrawl(r.stdout) };
168
+ }
169
+
170
+ // ── probing ─────────────────────────────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * GET the origin of a candidate. `requireSignature` (used for the unconfirmed localhost:3002 guess)
174
+ * also requires the body to look like Firecrawl, so an unrelated service on that port is not used.
175
+ */
176
+ export async function probeFirecrawlBase(baseUrl, { httpFetch = globalThis.fetch, timeoutMs = 3000, requireSignature = false } = {}) {
177
+ const origin = originOf(baseUrl);
178
+ if (!origin) return { up: false, reason: `invalid URL ${baseUrl}` };
179
+ try {
180
+ const res = await httpFetch(`${origin}/`, { method: 'GET', signal: AbortSignal.timeout(timeoutMs) });
181
+ if (!requireSignature) return { up: true, status: res?.status ?? 0 };
182
+ let body = '';
183
+ try {
184
+ body = typeof res?.text === 'function' ? await res.text() : JSON.stringify(await res.json());
185
+ } catch {
186
+ body = '';
187
+ }
188
+ if (/firecrawl|SCRAPERS-JS/i.test(body)) return { up: true, status: res?.status ?? 0 };
189
+ return { up: false, reason: `${origin} answered but is not a Firecrawl API` };
190
+ } catch (e) {
191
+ return { up: false, reason: `${origin} not reachable (${e?.cause?.code || e?.name || e?.message || e})` };
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Find a local self-hosted Firecrawl. Never throws, never returns a hosted URL.
197
+ * @returns {Promise<{ok:true, baseUrl:string, source:'override'|'docker'|'default', container?:string}
198
+ * | {ok:false, reason:string, docker:string}>}
199
+ */
200
+ export async function detectLocalFirecrawl({ env = process.env, runDocker = runDockerPs, httpFetch = globalThis.fetch, timeoutMs = 3000 } = {}) {
201
+ const candidates = [];
202
+ const seen = new Set();
203
+ const notes = [];
204
+ const push = (url, source, extra = {}) => {
205
+ const u = normalizeFirecrawlBaseUrl(url);
206
+ if (!u || isHostedFirecrawlUrl(u) || seen.has(u)) return;
207
+ seen.add(u);
208
+ candidates.push({ baseUrl: u, source, ...extra });
209
+ };
210
+
211
+ for (const key of ['HEYSPEX_FIRECRAWL_URL', 'FIRECRAWL_BASE_URL']) {
212
+ const v = String(env?.[key] ?? '').trim();
213
+ if (!v) continue;
214
+ if (isHostedFirecrawlUrl(v)) notes.push(`${key} points at hosted Firecrawl and was ignored`);
215
+ else push(v, 'override');
216
+ }
217
+
218
+ const docker = dockerFirecrawlContainers({ runDocker });
219
+ for (const c of docker.containers) {
220
+ for (const u of c.baseUrls) push(u, 'docker', { container: c.name });
221
+ }
222
+ for (const u of DEFAULT_LOCAL_FIRECRAWL_BASE_URLS) push(u, 'default');
223
+
224
+ const reasons = [];
225
+ for (const c of candidates) {
226
+ const probe = await probeFirecrawlBase(c.baseUrl, { httpFetch, timeoutMs, requireSignature: c.source === 'default' });
227
+ if (probe.up) return { ok: true, baseUrl: c.baseUrl, source: c.source, ...(c.container ? { container: c.container } : {}) };
228
+ reasons.push(probe.reason);
229
+ }
230
+
231
+ let headline;
232
+ if (docker.state === 'missing') headline = 'Docker is not installed or not on PATH';
233
+ else if (docker.state === 'down') headline = 'Docker is installed but not running';
234
+ else if (!docker.containers.length) headline = 'Docker is running but no Firecrawl API container is up';
235
+ else if (!docker.containers.some((c) => c.baseUrls.length)) headline = `Firecrawl container ${docker.containers[0].name} publishes no host port`;
236
+ else headline = `Firecrawl container ${docker.containers[0].name} is up but not answering yet (still starting? retry in a minute)`;
237
+ const detail = [...notes, ...reasons].join('; ');
238
+ return { ok: false, docker: docker.state, reason: `${headline}${detail ? ` (${detail})` : ''}` };
239
+ }
240
+
241
+ // ── search / scrape against a local instance (no API key, no credits) ─────────────────────────────
242
+
243
+ async function postJson(baseUrl, path, body, { httpFetch, timeoutMs }) {
244
+ const res = await httpFetch(`${baseUrl}${path}`, {
245
+ method: 'POST',
246
+ headers: { 'Content-Type': 'application/json' },
247
+ body: JSON.stringify(body),
248
+ signal: AbortSignal.timeout(timeoutMs),
249
+ });
250
+ let json = null;
251
+ try {
252
+ json = await res.json();
253
+ } catch {
254
+ json = null;
255
+ }
256
+ return { res, json };
257
+ }
258
+
259
+ /** @returns {Promise<{ok:true, results:Array<{title,url,snippet}>} | {ok:false, error:string}>} */
260
+ export async function localFirecrawlSearch(baseUrl, query, { limit = 6, httpFetch = globalThis.fetch, timeoutMs = 45000 } = {}) {
261
+ if (isHostedFirecrawlUrl(baseUrl)) return { ok: false, error: 'refused: hosted Firecrawl endpoint' };
262
+ try {
263
+ const { res, json } = await postJson(baseUrl, '/search', { query, limit, sources: [{ type: 'web' }] }, { httpFetch, timeoutMs });
264
+ if (!res?.ok || json?.success === false) return { ok: false, error: json?.error || `HTTP ${res?.status ?? 0}` };
265
+ const rows = json?.data?.web ?? (Array.isArray(json?.data) ? json.data : []);
266
+ return {
267
+ ok: true,
268
+ results: rows.map((r) => ({
269
+ title: r.title ?? '',
270
+ url: r.url ?? '',
271
+ snippet: r.description ?? (typeof r.markdown === 'string' ? r.markdown.slice(0, 300) : ''),
272
+ })),
273
+ };
274
+ } catch (e) {
275
+ return { ok: false, error: e?.message || String(e) };
276
+ }
277
+ }
278
+
279
+ /** @returns {Promise<{ok:true, title:string, markdown:string, status:number} | {ok:false, error:string}>} */
280
+ export async function localFirecrawlScrape(baseUrl, url, { httpFetch = globalThis.fetch, timeoutMs = 90000 } = {}) {
281
+ if (isHostedFirecrawlUrl(baseUrl)) return { ok: false, error: 'refused: hosted Firecrawl endpoint' };
282
+ try {
283
+ const { res, json } = await postJson(baseUrl, '/scrape', { url, formats: ['markdown'], onlyMainContent: true }, { httpFetch, timeoutMs });
284
+ if (!res?.ok || json?.success === false) return { ok: false, error: json?.error || `HTTP ${res?.status ?? 0}` };
285
+ return {
286
+ ok: true,
287
+ title: json?.data?.metadata?.title ?? '',
288
+ markdown: json?.data?.markdown ?? '',
289
+ status: json?.data?.metadata?.statusCode ?? res?.status ?? 0,
290
+ };
291
+ } catch (e) {
292
+ return { ok: false, error: e?.message || String(e) };
293
+ }
294
+ }
@@ -45,6 +45,7 @@ import { readFileSync } from 'node:fs';
45
45
  import path from 'node:path';
46
46
  import { fileURLToPath } from 'node:url';
47
47
  import { resolveCooModeEnabled } from './fleet-readiness.mjs';
48
+ import { isDevFactoryOverlayOpen } from '../../vendor/heyspex-mcp/dev-factory-overlay.mjs';
48
49
 
49
50
  /** Repo root: honor CLAUDE_PROJECT_DIR, else two dirs up from dev-factory/lib. */
50
51
  export function repoRoot() {
@@ -52,6 +53,19 @@ export function repoRoot() {
52
53
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
53
54
  }
54
55
 
56
+ /**
57
+ * The AMBIENT repo root — the one a caller gets when it passes no root — or null when the
58
+ * owner dev-factory overlay is closed there ([[HS-R475-C86Z-TASK-load-the-owner-dev-factory-overlay-only]]).
59
+ * repoRoot() honours CLAUDE_PROJECT_DIR, so in another developer's repo (or an installed
60
+ * package) this is null and seats.json is never read: the owner's seats never inject into a
61
+ * customer session. An EXPLICIT root is the caller's own choice (fixtures, sync-seat-context)
62
+ * and is not re-gated.
63
+ */
64
+ export function ambientOverlayRoot(env = process.env) {
65
+ const root = repoRoot();
66
+ return isDevFactoryOverlayOpen(root, { env }) ? root : null;
67
+ }
68
+
55
69
  /** The single registry file every front reads. Relative to the repo root. */
56
70
  export const SEATS_REGISTRY_RELATIVE_PATH = path.join('dev-factory', 'seats.json');
57
71
 
@@ -80,9 +94,11 @@ export function seatsRegistryPath(root = repoRoot()) {
80
94
  * @param {string} [root]
81
95
  * @returns {object|null}
82
96
  */
83
- export function loadSeatRegistry(root = repoRoot()) {
97
+ export function loadSeatRegistry(root = undefined) {
98
+ const r = root ?? ambientOverlayRoot();
99
+ if (!r) return null;
84
100
  try {
85
- return JSON.parse(readFileSync(seatsRegistryPath(root), 'utf8'));
101
+ return JSON.parse(readFileSync(seatsRegistryPath(r), 'utf8'));
86
102
  } catch {
87
103
  return null;
88
104
  }
@@ -144,7 +160,7 @@ export function seatReferenceLine(seats) {
144
160
  * text: string|null,
145
161
  * }}
146
162
  */
147
- export function buildSeatContextModel({ root = repoRoot(), registry } = {}) {
163
+ export function buildSeatContextModel({ root, registry } = {}) {
148
164
  const reg = registry === undefined ? loadSeatRegistry(root) : registry;
149
165
  if (!reg) {
150
166
  return { registry: null, enabled: false, reason: 'registry missing or corrupt', inject: [], reference: [], text: null };
@@ -194,6 +210,6 @@ export function buildSeatContextModel({ root = repoRoot(), registry } = {}) {
194
210
  * @param {string} [root] repo root (defaults to repoRoot())
195
211
  * @returns {string|null}
196
212
  */
197
- export function buildSeatContext(root = repoRoot()) {
213
+ export function buildSeatContext(root = undefined) {
198
214
  return buildSeatContextModel({ root }).text;
199
215
  }