heyspex 0.2.4 → 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 (48) 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-log.md +1 -1
  27. package/dist/cli-templates/harness-commands/claude/hs-res.md +71 -63
  28. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
  29. package/dist/cli-templates/harness-commands/cursor/hs-log.md +1 -1
  30. package/dist/cli-templates/harness-commands/cursor/hs-res.md +66 -58
  31. package/dist/cli-templates/harness-commands/cursor/hs-review.md +18 -18
  32. package/docs/copilot-plugin-install.md +2 -2
  33. package/package.json +9 -5
  34. package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
  35. package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
  36. package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
  37. package/scripts/claude-statusline/statusline-command.mjs +287 -27
  38. package/scripts/hs-companion.mjs +8 -0
  39. package/scripts/install-claude-code-hooks.mjs +104 -3
  40. package/scripts/install-copilot-hooks.mjs +21 -1
  41. package/scripts/install-cursor-hooks.mjs +4 -3
  42. package/scripts/lib/capture-execution-guard.mjs +221 -0
  43. package/scripts/lib/customer-kit.mjs +48 -0
  44. package/scripts/lib/hs-companion-settings.mjs +25 -0
  45. package/scripts/record-grounding.mjs +38 -0
  46. package/vendor/heyspex-mcp/http-transport.mjs +1 -1
  47. package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
  48. package/bin/postinstall.mjs +0 -88
@@ -0,0 +1,221 @@
1
+ // scripts/lib/capture-execution-guard.mjs
2
+ //
3
+ // [[HS-KNJ8-BQZC-DIAG-an-agent-session-executed-past-capture]] — the deterministic
4
+ // "do not execute past an ungrounded capture" guard. Pure decision + tiny fs helpers.
5
+ // Consumed by:
6
+ // - scripts/claude-hooks/capture-execution-gate.mjs (PreToolUse, refuses)
7
+ // - scripts/lib/capture-verbatim-append.mjs (UserPromptSubmit, writes the state)
8
+ // - scripts/record-grounding.mjs (/hs-ground helper, writes the record)
9
+ //
10
+ // WHERE THE GATE LEARNS CAPTURE STATE (cheap + offline): ONE local file,
11
+ // `.heyspex/capture-gate-state.json` under the project root:
12
+ // { capture_id, session_id, expanded, written_at }
13
+ // It is written ONLY by the UserPromptSubmit stage after a LIVE resolve of the on-deck
14
+ // capture (console/Neon is the system of record; the offline capture-focus.yaml rots —
15
+ // HS-O4U6 — so the gate never trusts it) and by record-grounding.mjs. The PreToolUse gate
16
+ // reads nothing but that file and `.heyspex/research-grounding.json`: no network, no
17
+ // MCP, no capture-focus.mjs import (a Bash-time hook must stay fast).
18
+ //
19
+ // FAIL OPEN whenever state is unknowable: no hook session id, no state file, unreadable or
20
+ // malformed JSON, a state written by ANOTHER session, a state older than STATE_MAX_AGE_MS,
21
+ // a state with no capture_id (the prompt stage writes capture_id:null when it verified
22
+ // nothing is on deck for this session). Only a fresh, this-session, named, not-yet-cleared
23
+ // capture can ever refuse anything.
24
+ //
25
+ // Self-clearing: while a state exists the prompt stage re-verifies it on EVERY substantive
26
+ // prompt, so a promoted/closed capture stops gating at the next message; STATE_MAX_AGE_MS
27
+ // bounds the worst case when no prompt arrives.
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+
32
+ import {
33
+ GROUNDING_ARTIFACT_REL,
34
+ GROUNDING_FIELDS,
35
+ defaultReadGroundingArtifact,
36
+ } from '../../spexharness/src/gates/deep-research-cost-gate.mjs';
37
+
38
+ export const STATE_REL = path.join('.heyspex', 'capture-gate-state.json');
39
+ export const STATE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
40
+ /** Owner/env kill switch for the gate; the config twin is agent_behaviors.capture_execution_gate.enabled. */
41
+ export const GATE_OFF_ENV = 'HEYSPEX_CAPTURE_EXECUTION_GATE_OFF';
42
+ export const GATE_CONFIG_KEY = 'agent_behaviors.capture_execution_gate.enabled';
43
+
44
+ // ── state file ─────────────────────────────────────────────────────────────
45
+
46
+ export function readGateState(root) {
47
+ try {
48
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, STATE_REL), 'utf8'));
49
+ return parsed && typeof parsed === 'object' ? parsed : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export function writeGateState(root, state) {
56
+ try {
57
+ const full = path.join(root, STATE_REL);
58
+ fs.mkdirSync(path.dirname(full), { recursive: true });
59
+ fs.writeFileSync(
60
+ full,
61
+ JSON.stringify({ ...state, written_at: new Date().toISOString() }, null, 2) + '\n',
62
+ 'utf8',
63
+ );
64
+ return true;
65
+ } catch {
66
+ return false; // best effort — a failed write only means the gate fails open
67
+ }
68
+ }
69
+
70
+ /** Does this capture body carry an agent expansion? (same two shapes capture-focus.mjs reads) */
71
+ export function bodyHasExpansion(body) {
72
+ const text = String(body ?? '').replace(/\r\n/g, '\n');
73
+ if (/##[ \t]*Expanded[ \t]+Concept[ \t]*\n\s*\S/i.test(text)) return true;
74
+ return /<details>\s*\n<summary>\s*(?:Details|Expanded idea \(agent\))[^<\n]*<\/summary>\s*\n+\s*\S/i.test(text);
75
+ }
76
+
77
+ // ── grounding record ───────────────────────────────────────────────────────
78
+
79
+ /** Is `artifact` a complete grounding record for `captureId`? Same predicate as deep-research-cost-gate's groundingBlock. */
80
+ export function isGroundedFor(artifact, captureId) {
81
+ return Boolean(
82
+ artifact &&
83
+ typeof artifact === 'object' &&
84
+ captureId &&
85
+ artifact.source_idea_id === captureId &&
86
+ GROUNDING_FIELDS.every((k) => typeof artifact[k] === 'string' && artifact[k].trim()),
87
+ );
88
+ }
89
+
90
+ /**
91
+ * The /hs-ground helper's write: `.heyspex/research-grounding.json` in the EXACT shape
92
+ * deep-research-cost-gate reads, keyed to the capture, plus marks the gate state expanded
93
+ * (recording the grounding IS the expansion step of the Ground=Expand flow).
94
+ */
95
+ export function recordGrounding(root, { source_idea_id, already_built, already_decided, owner_reference, residual_delta }, { sessionId } = {}) {
96
+ const record = { source_idea_id, already_built, already_decided, owner_reference, residual_delta };
97
+ const missing = ['source_idea_id', ...GROUNDING_FIELDS].filter((k) => typeof record[k] !== 'string' || !record[k].trim());
98
+ if (missing.length) return { ok: false, error: `missing or empty: ${missing.join(', ')}` };
99
+ const full = path.join(root, GROUNDING_ARTIFACT_REL);
100
+ fs.mkdirSync(path.dirname(full), { recursive: true });
101
+ fs.writeFileSync(full, JSON.stringify({ ...record, recorded_at: new Date().toISOString() }, null, 2) + '\n', 'utf8');
102
+ const prior = readGateState(root);
103
+ if (prior?.capture_id === source_idea_id) {
104
+ writeGateState(root, { ...prior, session_id: sessionId ?? prior.session_id, expanded: true });
105
+ }
106
+ return { ok: true, path: full };
107
+ }
108
+
109
+ // ── surface classification ─────────────────────────────────────────────────
110
+
111
+ const PAID_SCRIPTS = ['firecrawl-cli', 'spexcrawl-cli', 'synthesize-cli', 'audit-via-deepseek', 'deepseek-build'];
112
+ const SCRIPT_RE = new RegExp(`(^|[\\s/\\\\"'=])(?:${PAID_SCRIPTS.join('|')})(?:\\.mjs|\\.js|\\.ts)?(?=$|[\\s"';|&)])`, 'i');
113
+ // :3002 on loopback OR any dotted-quad host (the owner's Firecrawl runs on the LAN, e.g. 192.168.1.252:3002).
114
+ const FIRECRAWL_URL_RE = /(?:127\.0\.0\.1|localhost|\d{1,3}(?:\.\d{1,3}){3}):3002|api\.firecrawl\.dev/i;
115
+ // A segment whose FIRST word is one of these only reads/inspects; never refused.
116
+ const READ_ONLY_HEAD = new Set([
117
+ 'cat', 'head', 'tail', 'less', 'more', 'grep', 'rg', 'egrep', 'fgrep', 'ls', 'dir', 'find', 'wc', 'type', 'echo',
118
+ 'git', 'sed', 'awk', 'stat', 'file', 'diff', 'tree', 'pwd', 'cd', 'which', 'where', 'get-content', 'select-string',
119
+ 'get-childitem', 'test-path', 'printf', 'sort', 'cut', 'jq',
120
+ ]);
121
+ // Offline verification of the tools themselves — tests, self-tests, help.
122
+ const OFFLINE_FLAG_RE = /(?:^|\s)(?:--test|--self-test|--help|-h)(?=\s|$)|\.(?:test|smoke)\.m?js\b/i;
123
+
124
+ function splitSegments(command) {
125
+ return String(command).split(/&&|\|\||;|\||\r?\n/).map((s) => s.trim()).filter(Boolean);
126
+ }
127
+
128
+ function headWord(segment) {
129
+ // skip leading env assignments (FOO=1 node ...) and a leading "cd x" is its own segment already
130
+ const words = segment.split(/\s+/);
131
+ let i = 0;
132
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i++;
133
+ return (words[i] ?? '').replace(/^["']|["']$/g, '').toLowerCase();
134
+ }
135
+
136
+ /** Which gated Bash surface (if any) does this command invoke? */
137
+ export function classifyBash(command) {
138
+ if (typeof command !== 'string' || !command.trim()) return null;
139
+ for (const seg of splitSegments(command)) {
140
+ if (READ_ONLY_HEAD.has(headWord(seg))) continue;
141
+ if (OFFLINE_FLAG_RE.test(seg)) continue;
142
+ if (FIRECRAWL_URL_RE.test(seg)) return { surface: 'firecrawl-api', detail: seg.slice(0, 120) };
143
+ if (SCRIPT_RE.test(seg)) {
144
+ const name = PAID_SCRIPTS.find((s) => seg.toLowerCase().includes(s));
145
+ return { surface: name ?? 'paid-research-script', detail: seg.slice(0, 120) };
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ // An Agent/Task prompt that tells a sub-agent to CHANGE research routing / the model catalog / a protocol.
152
+ const ROUTING_TARGET_RE =
153
+ /routing[\s_-]*table|model[\s_-]*catalog|dev-factory-delegation-routing|research[\s_-]*(?:routing|seat)|routing[\s_-]*(?:pins?|rules?|seats?|defaults?)|seats\.json|specs[\\/]protocols[\\/]|protocol (?:file|edit)|edit(?:ing)? (?:the )?protocol|(?:default|always)[^.\n]{0,60}\b(?:research|synthesis)\b/i;
154
+ const EDIT_VERB_RE =
155
+ /\b(?:edit|modify|change|update|rewrite|patch|set|make sure|ensure|always use|add|fix|implement|wire|configure|pin|route)\b/i;
156
+
157
+ export function classifyAgent(input) {
158
+ const text = `${typeof input?.prompt === 'string' ? input.prompt : ''}\n${typeof input?.description === 'string' ? input.description : ''}`;
159
+ if (!text.trim()) return null;
160
+ if (ROUTING_TARGET_RE.test(text) && EDIT_VERB_RE.test(text)) {
161
+ return { surface: 'routing-edit-agent', detail: text.trim().slice(0, 120) };
162
+ }
163
+ return null;
164
+ }
165
+
166
+ export function classifySurface(toolName, toolInput) {
167
+ const name = String(toolName ?? '').trim();
168
+ if (name === 'Bash' || name === 'PowerShell') return classifyBash(toolInput?.command);
169
+ if (name === 'Agent' || name === 'Task') return classifyAgent(toolInput);
170
+ return null;
171
+ }
172
+
173
+ // ── decision ───────────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * @param {{ toolName?: string, toolInput?: object, sessionId?: string }} evt
177
+ * @param {{ root: string, env?: object, now?: number, readState?: Function, readGrounding?: Function, configEnabled?: boolean }} ctx
178
+ * @returns {{ decision: 'allow'|'deny', reason?: string }}
179
+ */
180
+ export function decideCaptureExecution(evt, ctx) {
181
+ const env = ctx.env ?? process.env;
182
+ if (String(env[GATE_OFF_ENV] ?? '') === '1' || ctx.configEnabled === false) return { decision: 'allow' };
183
+ const hit = classifySurface(evt?.toolName, evt?.toolInput);
184
+ if (!hit) return { decision: 'allow' };
185
+ const sessionId = typeof evt?.sessionId === 'string' ? evt.sessionId.trim() : '';
186
+ if (!sessionId) return { decision: 'allow' }; // cannot prove it is THIS session's capture
187
+
188
+ const state = (ctx.readState ?? readGateState)(ctx.root);
189
+ const now = ctx.now ?? Date.now();
190
+ const writtenAt = Date.parse(state?.written_at ?? '');
191
+ if (
192
+ !state ||
193
+ !state.capture_id ||
194
+ state.session_id !== sessionId ||
195
+ !Number.isFinite(writtenAt) ||
196
+ now - writtenAt > STATE_MAX_AGE_MS
197
+ ) {
198
+ return { decision: 'allow' };
199
+ }
200
+
201
+ const grounded = isGroundedFor((ctx.readGrounding ?? defaultReadGroundingArtifact)(ctx.root), state.capture_id);
202
+ const expanded = state.expanded === true;
203
+ if (expanded && grounded) return { decision: 'allow' };
204
+
205
+ const why = [!expanded && 'it is still unexpanded', !grounded && `no grounding record for ${state.capture_id} exists`]
206
+ .filter(Boolean)
207
+ .join(' and ');
208
+ return {
209
+ decision: 'deny',
210
+ reason:
211
+ `Execution blocked (${hit.surface}): capture ${state.capture_id} is on deck for this session but ${why}. ` +
212
+ `Capture, ground and expand FIRST, then execute: (1) the owner's words must be in the capture verbatim; ` +
213
+ `(2) run /hs-ground on it (route-1 plans, route-2 code, route-3 the owner's own working reference); ` +
214
+ `(3) record the result with node scripts/record-grounding.mjs --capture ${state.capture_id} --built "..." --decided "..." --reference "..." --delta "..." ` +
215
+ `(writes ${GROUNDING_ARTIFACT_REL} and marks the capture expanded); (4) retry this exact call. ` +
216
+ `If the owner said to discard the capture or to just do it, promote/close the capture first. ` +
217
+ `Read-only tools, capture writes, /hs-ground, git and test runs are never gated. ` +
218
+ `Only the owner can lift this gate wholesale: set ${GATE_OFF_ENV}=1 in their own environment ` +
219
+ `(or agent_behaviors.capture_execution_gate.enabled: false).`,
220
+ };
221
+ }
@@ -1326,6 +1326,54 @@ export function auditCustomerKit({ targetRoot, platformRoot, deps = fsDeps }) {
1326
1326
  return report;
1327
1327
  }
1328
1328
 
1329
+ // ---------------------------------------------------------------------------
1330
+ // Which repo `hs setup <agent>` writes into
1331
+ // ---------------------------------------------------------------------------
1332
+
1333
+ /**
1334
+ * The project root a setup verb acts on: the nearest directory at/above
1335
+ * `start` holding `.git` or `heyspex.yaml`, else `start` itself. Same walk
1336
+ * `hs setup copilot` uses.
1337
+ * @param {string} start
1338
+ */
1339
+ export function findProjectRoot(start) {
1340
+ let dir = path.resolve(start);
1341
+ for (;;) {
1342
+ if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'heyspex.yaml'))) return dir;
1343
+ const up = path.dirname(dir);
1344
+ if (up === dir) return path.resolve(start);
1345
+ dir = up;
1346
+ }
1347
+ }
1348
+
1349
+ /**
1350
+ * True when `platformRoot` is an installed `heyspex` package (under a
1351
+ * node_modules/ folder, or missing the source-checkout-only
1352
+ * scripts/heyspex-doctor.mjs) rather than a HeySpex source checkout.
1353
+ * @param {string} platformRoot
1354
+ * @param {(p: string) => boolean} [exists]
1355
+ */
1356
+ export function isInstalledPackageRoot(platformRoot, exists = fs.existsSync) {
1357
+ if (toPosixPath(path.resolve(platformRoot)).includes('/node_modules/')) return true;
1358
+ return !exists(path.join(platformRoot, 'scripts', 'heyspex-doctor.mjs'));
1359
+ }
1360
+
1361
+ /**
1362
+ * The repo a hook installer (`install-claude-code-hooks.mjs`,
1363
+ * `install-cursor-hooks.mjs`) writes into. An explicit HS_SETUP_TARGET (what
1364
+ * `hs setup` / `hs doctor --fix` pass) wins; with none, a source checkout
1365
+ * targets itself (the dev `npm install` / `npm run hooks:*` path) but an
1366
+ * INSTALLED package targets the customer's project found from cwd -- never
1367
+ * its own package folder under node_modules/.
1368
+ * @param {{ platformRoot: string, env?: Record<string, string | undefined>, cwd?: string, exists?: (p: string) => boolean }} opts
1369
+ */
1370
+ export function resolveSetupTarget({ platformRoot, env = process.env, cwd = process.cwd(), exists = fs.existsSync }) {
1371
+ const explicit = env.HS_SETUP_TARGET?.trim();
1372
+ if (explicit) return path.resolve(explicit);
1373
+ if (isInstalledPackageRoot(platformRoot, exists)) return findProjectRoot(cwd);
1374
+ return path.resolve(platformRoot);
1375
+ }
1376
+
1329
1377
  // ---------------------------------------------------------------------------
1330
1378
  // CLI: regenerate plugins/heyspex/hooks/hooks.json
1331
1379
  // ---------------------------------------------------------------------------
@@ -105,6 +105,31 @@ export const COMPANION_ALIASES = {
105
105
 
106
106
  export const HOST_COMMANDS = ["start", "stop", "status", "build"];
107
107
 
108
+ /**
109
+ * What `hs companion start|stop|status|build` prints when run from an
110
+ * installed `heyspex` npm package: the tray app's source (companion/) is not
111
+ * in the tarball, so there is no start.ps1 to run. Says plainly that the
112
+ * Companion is not distributed through npm yet (a Windows installer is
113
+ * planned, research HS-AUY9) and what degrades without it, with the
114
+ * workaround for each. The dev (repo checkout) path never reaches this.
115
+ * @param {string} sub the host subcommand the caller asked for
116
+ */
117
+ export function renderCompanionNotDistributed(sub) {
118
+ return [
119
+ `hs companion ${sub}: the HeySpex Companion (desktop tray app) is not distributed through the`,
120
+ "heyspex npm package yet; a Windows installer is planned (research HS-AUY9).",
121
+ "",
122
+ "Everything else in this package works without it. What you lose until then:",
123
+ " - Automatic vault mirror (cloud <-> disk sync): run `npx hs pull --apply` to bring",
124
+ " cloud-written specs down (and `npx hs push --apply` to send local edits up).",
125
+ " - SpexVoice (voice capture and spoken commands): not available.",
126
+ " - SpexCrawl research gather (/hs-res): its gather jobs need the Companion, so they",
127
+ " report SPEXCRAWL_UNAVAILABLE.",
128
+ "",
129
+ "`hs companion settings ...` (console-backed) still works.",
130
+ ].join("\n");
131
+ }
132
+
108
133
  const TRUE_WORDS = new Set(["true", "on", "yes", "1", "enabled"]);
109
134
  const FALSE_WORDS = new Set(["false", "off", "no", "0", "disabled"]);
110
135
  const NULL_WORDS = new Set(["null", "none"]);
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // /hs-ground helper: record the grounding result so the execution gate and the deep-research
3
+ // gate can see it. [[HS-KNJ8-BQZC-DIAG-an-agent-session-executed-past-capture]]
4
+ //
5
+ // node scripts/record-grounding.mjs --capture HS-CAP-XXXX --built "..." --decided "..." \
6
+ // --reference "..." --delta "..." [--root <project root>] [--session <id>]
7
+ //
8
+ // Writes .heyspex/research-grounding.json as
9
+ // { source_idea_id, already_built, already_decided, owner_reference, residual_delta, recorded_at }
10
+ // (the shape deep-research-cost-gate.mjs already reads) and marks the capture expanded in
11
+ // .heyspex/capture-gate-state.json. Never calls a model or the network.
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { recordGrounding } from './lib/capture-execution-guard.mjs';
15
+
16
+ export function parseArgs(argv) {
17
+ const out = {};
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const a = argv[i];
20
+ if (a.startsWith('--')) out[a.slice(2)] = argv[++i];
21
+ }
22
+ return out;
23
+ }
24
+
25
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
26
+ const a = parseArgs(process.argv.slice(2));
27
+ const root = a.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
28
+ const res = recordGrounding(
29
+ root,
30
+ { source_idea_id: a.capture, already_built: a.built, already_decided: a.decided, owner_reference: a.reference, residual_delta: a.delta },
31
+ { sessionId: a.session || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_SESSION_ID },
32
+ );
33
+ if (!res.ok) {
34
+ process.stderr.write(`record-grounding: ${res.error}\nusage: --capture <id> --built --decided --reference --delta (all required)\n`);
35
+ process.exit(1);
36
+ }
37
+ process.stdout.write(`grounding recorded: ${res.path}\n`);
38
+ }
@@ -111,7 +111,7 @@ export function toolNotAvailableRemotelyError(id, name) {
111
111
  * @property {(name: string) => boolean} [isKnownTool]
112
112
  * Whether a name exists in the FULL catalog — distinguishes "needs the local
113
113
  * server" from "no such tool" (default: same as `isToolAllowed`).
114
- * @property {{ name: string, version: string }} serverInfo
114
+ * @property {{ name: string, version: string, title?: string, icons?: ReadonlyArray<{ src: string, mimeType?: string, sizes?: ReadonlyArray<string> }> }} serverInfo
115
115
  * @property {string} [instructions]
116
116
  * @property {string} vaultRoot
117
117
  * Passed through to `dispatch`. For the remote endpoint this is a path that
@@ -3,9 +3,9 @@
3
3
  */
4
4
 
5
5
  import { randomUUID } from 'node:crypto';
6
- import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
- import { join } from 'node:path';
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
9
 
10
10
  import { resetActiveConfigCache } from './vault-resolve.mjs';
11
11
 
@@ -134,35 +134,49 @@ export function installScopedConsoleEnv({
134
134
  * part of `scripts/`) also imports `vendor/heyspex-mcp/vault-resolve.mjs` by a
135
135
  * relative path OUTSIDE `scripts/` — that file must be mirrored too, or the
136
136
  * copied validator dangles with MODULE_NOT_FOUND the moment it computes its
137
- * own default root. `vault-resolve.mjs` itself has no further local-file
138
- * imports (node builtins only), so this is a single extra copy, not a chain.
137
+ * own default root, and that file in turn imports `vault-binding.mjs` →
138
+ * `usable-dir-env.mjs` (a chain, not a single copy).
139
139
  *
140
- * Single source of truth: the next dependency the validator grows is a one-line
141
- * add here, not a silent breakage in every round-trip test.
140
+ * The mirror is DERIVED, not hand-listed: a hardcoded list rotted three times
141
+ * as modules grew imports (last miss: `usable-dir-env.mjs`, HS-PNBC). We walk
142
+ * relative import specifiers transitively from the validator and copy every
143
+ * reached file that lives outside `scripts/` (which is copied wholesale).
142
144
  *
143
145
  * @param {string} repoRoot Absolute path to the real repo root.
144
146
  * @param {string} root Absolute path to the temp vault being assembled.
145
147
  */
146
148
  export function mirrorValidatorDeps(repoRoot, root) {
147
149
  cpSync(join(repoRoot, 'scripts'), join(root, 'scripts'), { recursive: true });
148
-
149
- mkdirSync(join(root, 'vendor', 'heyspex-mcp'), { recursive: true });
150
- // vault-resolve.mjs imports vault-binding.mjs (the repo binding, HS-TYJA).
151
- for (const file of ['vault-resolve.mjs', 'vault-binding.mjs']) {
152
- const rel = join('vendor', 'heyspex-mcp', file);
153
- cpSync(join(repoRoot, rel), join(root, rel));
150
+ for (const abs of localImportClosure(join(repoRoot, 'scripts', 'validate-vault-ids.mjs'))) {
151
+ const rel = relative(repoRoot, abs);
152
+ if (rel.startsWith('..') || isAbsolute(rel)) continue;
153
+ if (rel.split(sep)[0] === 'scripts') continue; // already copied wholesale
154
+ mkdirSync(dirname(join(root, rel)), { recursive: true });
155
+ cpSync(abs, join(root, rel));
154
156
  }
157
+ }
158
+
159
+ // Relative specifiers in `from '…'`, side-effect `import '…'`, and literal `import('…')`.
160
+ const LOCAL_IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.{1,2}\/[^'"]+)\1/g;
155
161
 
156
- const indexerRel = join('spex-console', 'src', 'lib', 'indexer');
157
- mkdirSync(join(root, indexerRel), { recursive: true });
158
- for (const file of [
159
- 'vault-rules.mjs',
160
- 'vault-naming-rules.generated.mjs',
161
- // validate-vault-ids.mjs imports handoff-envelope.mjs (split/defer lineage rule).
162
- 'handoff-envelope.mjs',
163
- ]) {
164
- const rel = join(indexerRel, file);
165
- const src = join(repoRoot, rel);
166
- if (existsSync(src)) cpSync(src, join(root, rel));
162
+ /**
163
+ * Every local file transitively reachable from `entry` via relative import
164
+ * specifiers (bare/builtin specifiers ignored). A missing target is skipped so
165
+ * the copied script fails loudly with MODULE_NOT_FOUND instead of here.
166
+ *
167
+ * @param {string} entry Absolute path to the entry module.
168
+ * @returns {Set<string>} Absolute paths, entry included.
169
+ */
170
+ export function localImportClosure(entry) {
171
+ const seen = new Set();
172
+ const stack = [entry];
173
+ while (stack.length) {
174
+ const file = stack.pop();
175
+ if (seen.has(file) || !existsSync(file)) continue;
176
+ seen.add(file);
177
+ for (const m of readFileSync(file, 'utf8').matchAll(LOCAL_IMPORT_RE)) {
178
+ stack.push(resolve(dirname(file), m[2]));
179
+ }
167
180
  }
181
+ return seen;
168
182
  }
@@ -1,88 +0,0 @@
1
- #!/usr/bin/env node
2
- // Root postinstall guard. The dev-machine setup steps (PM control-center deps,
3
- // Claude Code hooks, CommandCode update) live under scripts/, which
4
- // .vercelignore excludes from the Vercel upload — so on Vercel (or any CI, or
5
- // any environment where scripts/ is absent) this must exit 0 instead of
6
- // crashing `npm install` with MODULE_NOT_FOUND. Lives in bin/ (uploaded to
7
- // Vercel) precisely so the guard itself always resolves.
8
- import { existsSync } from "node:fs";
9
- import { spawnSync } from "node:child_process";
10
- import { dirname, join } from "node:path";
11
- import { fileURLToPath, pathToFileURL } from "node:url";
12
-
13
- const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
14
-
15
- // Installed as somebody else's dependency (HS-ZJSP): the published `heyspex`
16
- // package ships this file because npm runs the `postinstall` script for every
17
- // consumer, but NONE of the dev-machine setup below belongs in a customer's
18
- // install — and printing a "skipping" line at them is noise, not information.
19
- // A package installed from the registry always lands under a node_modules/
20
- // directory; a clone of this repo never does. Exit silently in the former case.
21
- if (repoRoot.replace(/\\/g, "/").includes("/node_modules/")) {
22
- process.exit(0);
23
- }
24
-
25
- const steps = [
26
- ["scripts/ensure-pm-cc-deps.mjs"],
27
- ["scripts/install-claude-code-hooks.mjs"],
28
- // Cursor's two install surfaces. `.cursor/mcp.json` is gitignored (it is
29
- // machine-local), so without this step a fresh clone opens in Cursor with no
30
- // heyspex MCP server at all; `.cursor/hooks.json` is tracked but merges
31
- // idempotently, and the installer also retires superseded registrations.
32
- ["scripts/install-cursor-mcp.mjs"],
33
- ["scripts/install-cursor-hooks.mjs"],
34
- ["scripts/install-commandcode.mjs", "--update"],
35
- ["scripts/sync-skills.mjs"],
36
- // Git hooks (.git/hooks/ is never cloned) — installs pre-commit + pre-push so a
37
- // single `npm install` on a fresh clone is enough, with no second `npm run setup`.
38
- // No --force: an idempotent re-run no-ops on the HeySpex hook and refuses to
39
- // clobber a foreign pre-commit (exit 1 surfaces that rather than silently replacing).
40
- ["scripts/install-git-hooks.mjs"],
41
- ];
42
-
43
- if (process.env.VERCEL || process.env.CI) {
44
- console.log("[postinstall] VERCEL/CI detected — skipping dev-machine setup.");
45
- process.exit(0);
46
- }
47
-
48
- for (const [script, ...args] of steps) {
49
- const abs = join(repoRoot, script);
50
- if (!existsSync(abs)) {
51
- console.log(`[postinstall] ${script} not present — skipping dev-machine setup.`);
52
- process.exit(0);
53
- }
54
- const { status } = spawnSync(process.execPath, [abs, ...args], {
55
- cwd: repoRoot,
56
- stdio: "inherit",
57
- });
58
- if (status !== 0) process.exit(status ?? 1);
59
- }
60
-
61
- // Advisory only, never fatal, and deliberately NOT `npm link` itself: a
62
- // postinstall that mutates the machine's global npm prefix would repoint `hs`
63
- // away from whatever clone owned it, silently, on every install. `npm install`
64
- // completing clean while `hs` resolves nowhere is exactly the fresh-machine
65
- // failure in [[HS-BFXF-WBJU-DIAG-hs-never-resolves-on-a-fresh-machine]] — so
66
- // say so here, where a new operator is already looking, and let `npm run
67
- // doctor` carry the full check. Imported dynamically so the published package
68
- // (which ships this file but not scripts/) can never fail to resolve it.
69
- const hsLinkLib = join(repoRoot, "scripts/lib/hs-link.mjs");
70
- if (existsSync(hsLinkLib)) {
71
- try {
72
- const { describeHsLink } = await import(pathToFileURL(hsLinkLib).href);
73
- const link = describeHsLink(repoRoot);
74
- if (link.status !== "linked") {
75
- const what = {
76
- missing: "does not resolve on PATH",
77
- foreign: `resolves to an unrelated tool (${link.target})`,
78
- "other-checkout": `resolves to a DIFFERENT checkout (${link.target})`,
79
- unreadable: `resolves to ${link.shim}, whose target could not be read`,
80
- }[link.status];
81
- console.log(`[postinstall] heads up: \`hs\` ${what}.`);
82
- console.log("[postinstall] Run `npm link` once from this repo root to put hs/heyspex/heyspex-mcp on PATH.");
83
- console.log("[postinstall] Until then use `node bin/hs.mjs …` — never `npx hs` (unrelated package). Details: `npm run doctor`.");
84
- }
85
- } catch {
86
- // An advisory that crashes the install would be worse than no advisory.
87
- }
88
- }