heyspex 0.2.5 → 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 (110) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/bin/hs.mjs +349 -38
  3. package/bin/spexcode.mjs +31 -0
  4. package/dev-factory/lib/dispatch-guard.mjs +75 -0
  5. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  6. package/dev-factory/lib/runner-path.mjs +51 -0
  7. package/dev-factory/lib/seat-context.mjs +20 -4
  8. package/dev-factory/model-catalog.json +222 -222
  9. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  10. package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
  11. package/dist/cli/hs-connect.mjs +40 -3
  12. package/dist/cli/hs-defer.mjs +3817 -3741
  13. package/dist/cli/hs-events.mjs +1296 -0
  14. package/dist/cli/hs-export-events.mjs +1252 -0
  15. package/dist/cli/hs-handoff.mjs +2576 -0
  16. package/dist/cli/hs-index-rebuild.mjs +228 -37
  17. package/dist/cli/hs-index-reconcile.mjs +231 -40
  18. package/dist/cli/hs-init.mjs +22 -3
  19. package/dist/cli/hs-install.mjs +22 -3
  20. package/dist/cli/hs-join.mjs +40 -3
  21. package/dist/cli/hs-next-launch.mjs +2777 -0
  22. package/dist/cli/hs-pin.mjs +1336 -0
  23. package/dist/cli/hs-prereq.mjs +1380 -0
  24. package/dist/cli/hs-revalidate.mjs +3257 -0
  25. package/dist/cli/hs-spawn.mjs +12403 -0
  26. package/dist/cli/hs-test.mjs +1046 -0
  27. package/dist/cli/hs-trash.mjs +362 -0
  28. package/dist/cli/hs-trust.mjs +197 -0
  29. package/dist/cli/hs-vault-pull.mjs +233 -42
  30. package/dist/cli/hs-vault-push.mjs +231 -40
  31. package/dist/cli/hs-vault.mjs +21 -7
  32. package/dist/cli/hs-workspace.mjs +19 -1
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  42. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  43. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  44. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  45. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  46. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  47. package/dist/cli-templates/harness-commands/claude/hs-res.md +83 -63
  48. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  49. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  50. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  51. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  52. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  53. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  54. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  55. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  56. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  57. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  58. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  59. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  60. package/dist/cli-templates/harness-commands/cursor/hs-res.md +78 -58
  61. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  62. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  63. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  64. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  65. package/docs/copilot-plugin-install.md +9 -2
  66. package/docs/mcp-client-config.md +6 -0
  67. package/package.json +266 -257
  68. package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
  69. package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
  70. package/plugins/heyspex/README.md +6 -0
  71. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  72. package/plugins/heyspex/hooks/hooks.json +418 -418
  73. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  74. package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
  75. package/scripts/claude-statusline/statusline-command.mjs +287 -27
  76. package/scripts/hs-companion.mjs +41 -3
  77. package/scripts/install-claude-code-hooks.mjs +104 -3
  78. package/scripts/install-copilot-hooks.mjs +21 -1
  79. package/scripts/install-cursor-hooks.mjs +4 -3
  80. package/scripts/lib/capture-execution-guard.mjs +221 -0
  81. package/scripts/lib/companion-install.mjs +768 -0
  82. package/scripts/lib/customer-kit.mjs +53 -3
  83. package/scripts/lib/hs-companion-settings.mjs +26 -0
  84. package/scripts/lib/hs-source-push.mjs +386 -386
  85. package/scripts/record-grounding.mjs +38 -0
  86. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  87. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  88. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  90. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  91. package/spexharness/src/seat-context.mjs +10 -5
  92. package/spexharness/src/session-bootstrap.mjs +6 -3
  93. package/vendor/heyspex-mcp/cli.mjs +6 -1
  94. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  95. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  96. package/vendor/heyspex-mcp/http-transport.mjs +1 -1
  97. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  98. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  99. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  100. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  101. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  102. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  103. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  104. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  105. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  106. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  107. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  108. package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
  109. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
  110. package/bin/postinstall.mjs +0 -88
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dispatch-guard.mjs — pre-dispatch checks the controller runs BEFORE its first builder
4
+ * call, so a dispatch that can never pass its own gate is refused up front with the fix
5
+ * named (exit 3, gate_environment_fault) instead of burning iterations on it.
6
+ *
7
+ * Born 2026-08-26 (HS-VHQG): the frozen check ran a test file that did not exist, the
8
+ * single-target builder only writes its target, and the controller spent 3 iterations x
9
+ * (best-of-3 + self-repair) + 3 DeepSeek diagnoses before escalating "no progress:
10
+ * identical failure signature x3" — a harness fault reported as a model fault.
11
+ *
12
+ * node dev-factory/lib/dispatch-guard.mjs --self-test
13
+ */
14
+
15
+ /** Normalize a repo path for loose comparison (posix separators, no leading ./). */
16
+ function normRel(p) {
17
+ return String(p ?? "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
18
+ }
19
+
20
+ /** True when the builder's --target set (comma-separated) can WRITE the scoped test file —
21
+ * i.e. the test is itself a target. The builder only ever writes its targets. */
22
+ export function buildCanCreateTest(targetArg, testArg) {
23
+ const test = normRel(testArg);
24
+ if (!test) return true; // no scoped test → nothing to guard
25
+ const targets = String(targetArg ?? "").split(",").map((s) => normRel(s.trim())).filter(Boolean);
26
+ return targets.some((t) => t === test || t.endsWith(`/${test}`) || test.endsWith(`/${t}`));
27
+ }
28
+
29
+ /** Plain-language abort reason for the pre-dispatch guard (rides the exit-3 path). */
30
+ export function impossibleTestReason(targetArg, testArg) {
31
+ return `the scoped test does not exist yet and this build cannot create it — the frozen check runs ${testArg}, `
32
+ + `but this controller builds ONE target (--target ${targetArg}) and the builder only writes its target. `
33
+ + `No candidate can ever turn this gate green, so no builder was called and no iteration was spent. `
34
+ + `This task needs two files (the implementation + a new test), which is the routing table's "multi-file implementation" shape: `
35
+ + `route it to the multi-file seat (a harness sub-agent with an explicit cheap model — never the single-target fleet build), `
36
+ + `or split the spec so the test file lands as its own greenfield task first (--target ${testArg}) and this task's gate can then find it.`;
37
+ }
38
+
39
+ /** vitest's "No test files found" banner (it prints its include/exclude globs after it) —
40
+ * a gate that found nothing to run, never a red test. */
41
+ export function isNoTestFilesOutput(output) {
42
+ const s = String(output ?? "");
43
+ if (/No test files found/i.test(s)) return true;
44
+ // Older vitest prints only the include/exclude listing; treat it as no-test-files ONLY when
45
+ // nothing in the output looks like a test result.
46
+ // No \b here: vitest wraps the labels in dim ANSI codes ("[2minclude:") that glue to the word.
47
+ return /include:\s/.test(s) && /exclude:\s/.test(s)
48
+ && !/\b(passed|failed)\b.*\btests?\b/i.test(s) && !/✓|✗|×|FAIL\s+src/.test(s);
49
+ }
50
+
51
+ // ── self-test ────────────────────────────────────────────────────────────────────────
52
+ function runSelfTest() {
53
+ let ok = true;
54
+ const check = (name, cond) => { console.log(` ${cond ? "✓" : "✗"} ${name}`); if (!cond) ok = false; };
55
+ console.log("== dispatch-guard.mjs self-test ==\n");
56
+ check("impl target + new test (HS-VHQG shape) → cannot create", !buildCanCreateTest("spex-console/src/lib/cli/init.ts", "spex-console/src/lib/cli/init.customer-vault.test.ts"));
57
+ check("test IS the target (greenfield test task) → can create", buildCanCreateTest("spex-console/src/w.test.ts", "spex-console/src/w.test.ts"));
58
+ check("comma target set containing the test → can create", buildCanCreateTest("a/x.ts,a/x.test.ts", "a/x.test.ts"));
59
+ check("runner-relative test vs repo-relative target still matches", buildCanCreateTest("spex-console/src/w.test.ts", "src/w.test.ts"));
60
+ check("backslash paths normalize", buildCanCreateTest("spex-console\\src\\w.test.ts", "spex-console/src/w.test.ts"));
61
+ check("no scoped test → nothing to guard", buildCanCreateTest("a.ts", ""));
62
+ const reason = impossibleTestReason("a.ts", "a.test.ts");
63
+ check("reason names both files, the multi-file seat, and the greenfield split", /a\.test\.ts/.test(reason) && /--target a\.ts/.test(reason) && /multi-file seat/.test(reason) && /greenfield/.test(reason));
64
+ check("vitest no-test-files banner → environmental", isNoTestFilesOutput("No test files found, exiting with code 1\ninclude: src/**/*.test.ts\nexclude: **/node_modules/**"));
65
+ check("include/exclude listing with no results → environmental", isNoTestFilesOutput("include: src/**/*.test.ts\nexclude: **/node_modules/**"));
66
+ check("a real failing test run is NOT environmental", !isNoTestFilesOutput("FAIL src/lib/x.test.ts > does thing\nTests 1 failed | 2 passed (3)\ninclude: src/**/*.test.ts exclude: **/node_modules/**"));
67
+ check("a passing run is NOT environmental", !isNoTestFilesOutput("✓ src/x.test.ts (3 tests)\nTests 3 passed (3)"));
68
+ check("empty output is NOT environmental", !isNoTestFilesOutput(""));
69
+ console.log(ok ? "\n✓ dispatch-guard self-test passed" : "\n✗ dispatch-guard self-test FAILED");
70
+ return ok ? 0 : 1;
71
+ }
72
+
73
+ if (process.argv.includes("--self-test") && process.argv[1] && /dispatch-guard\.mjs$/.test(process.argv[1].replace(/\\/g, "/"))) {
74
+ process.exit(runSelfTest());
75
+ }
@@ -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
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * runner-path.mjs — make a scoped test/target path relative to the cwd its runner
3
+ * actually executes in.
4
+ *
5
+ * Born 2026-10-15 (HS-7BYJ baseline): the controller's red-baseline pre-check ran scoped
6
+ * vitest from `<worktree>/spex-console` (where the vitest config lives) but handed it the
7
+ * REPO-relative test path `spex-console/src/lib/x.test.ts`. vitest's file filter then
8
+ * matched nothing ("No test files found, exiting with code 1") and the run was classified
9
+ * as gate_environment_fault before any builder ran. One helper, used at every call site
10
+ * that runs a tool from a package subdirectory, instead of patching one site.
11
+ */
12
+ import { isAbsolute, relative } from "node:path";
13
+
14
+ const toPosix = (p) => String(p ?? "").replace(/\\/g, "/");
15
+ const stripDot = (p) => p.replace(/^(\.\/)+/, "");
16
+ const caseFold = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
17
+
18
+ /** `child` is `base` or under it (posix strings, case-insensitive on Windows). */
19
+ function isUnder(child, base) {
20
+ const c = caseFold(child), b = caseFold(base).replace(/\/+$/, "");
21
+ return c === b || c.startsWith(`${b}/`);
22
+ }
23
+
24
+ /**
25
+ * Convert `path` to a path relative to the runner's cwd.
26
+ *
27
+ * @param {string} path repo-relative ("spex-console/src/x.test.ts"), already
28
+ * cwd-relative ("src/x.test.ts"), absolute, or backslashed.
29
+ * @param {object} [opts]
30
+ * @param {string} [opts.cwdRel=""] the runner's cwd, relative to the repo/worktree root
31
+ * ("spex-console", "companion", "vendor/heyspex-mcp"; "" = root).
32
+ * @param {string[]} [opts.roots=[]] absolute roots an absolute `path` may live under (the
33
+ * gate worktree, the main repo) — it is re-rooted against the first
34
+ * match. An absolute path under none of them is returned posix-ified.
35
+ * @returns {string} posix path relative to `cwdRel`. A repo-relative path OUTSIDE `cwdRel`
36
+ * (e.g. vendor/… while the runner sits in spex-console) is returned
37
+ * unchanged — it is either already cwd-relative or not this runner's file.
38
+ */
39
+ export function toRunnerRelativePath(path, { cwdRel = "", roots = [] } = {}) {
40
+ let p = stripDot(toPosix(path).trim());
41
+ if (!p) return p;
42
+ const cwd = stripDot(toPosix(cwdRel)).replace(/^\/+|\/+$/g, "");
43
+ if (isAbsolute(p) || /^[A-Za-z]:\//.test(p)) {
44
+ const root = roots.map((r) => toPosix(r).replace(/\/+$/, "")).find((r) => r && isUnder(p, r));
45
+ if (!root) return p;
46
+ p = p.slice(root.length).replace(/^\/+/, "");
47
+ }
48
+ if (!cwd) return p;
49
+ if (isUnder(p, cwd)) return toPosix(relative(cwd, p)) || ".";
50
+ return p;
51
+ }
@@ -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
  }