@davesheffer/hunch 1.32.2 → 1.32.4

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.
@@ -509,6 +509,14 @@ const READ_REMOTE_TIMEOUT_MS = 5_000;
509
509
  // drains every already-durable JSON write itself. This removes the old "maybe a
510
510
  // third capture sweeps it later" liveness hole.
511
511
  const CAPTURE_LOCK_HANDOFF_MS = 120_000;
512
+ /** Longest one git call inside a memory flush may take before it is stopped and the flush
513
+ * reports durability "local" (HUNCH_COMMIT_GIT_TIMEOUT_MS overrides; tests use a short one). */
514
+ const COMMIT_GIT_TIMEOUT_MS = 60_000;
515
+ const SLOW_FLUSH_MS = 5_000;
516
+ function commitGitTimeoutMs() {
517
+ const raw = Number(process.env.HUNCH_COMMIT_GIT_TIMEOUT_MS);
518
+ return Number.isFinite(raw) && raw > 0 ? raw : COMMIT_GIT_TIMEOUT_MS;
519
+ }
512
520
  function unsafeOverlayPublication(hunchDir, protectedRepoRoot) {
513
521
  let currentOverlayRoot = dirname(resolve(hunchDir));
514
522
  try {
@@ -566,10 +574,21 @@ export function commitAndPushHunch(hunchDir, message, opts) {
566
574
  for (let attempt = 0; attempt < 2; attempt++) {
567
575
  const startedAt = Date.now();
568
576
  try {
569
- execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
577
+ // A served write blocks on this call: bound it, and never let a commit trigger git's
578
+ // automatic gc (minutes of repacking inside one write, fnd_4318727d35). A timed-out
579
+ // call returns false, the flush reports durability "local", and the next flush
580
+ // sweeps the same files up — nothing is lost, and the server is not frozen.
581
+ execFileSync("git", ["-C", hunchDir, "-c", "gc.auto=0", ...args], { stdio: "ignore", env, timeout: commitGitTimeoutMs() });
582
+ const took = Date.now() - startedAt;
583
+ if (took > SLOW_FLUSH_MS)
584
+ console.error(`hunch: git ${args.find((a) => !a.startsWith("-") && a !== "core.autocrlf=false") ?? args[0]} in "${hunchDir}" took ${took} ms`);
570
585
  return true;
571
586
  }
572
587
  catch (error) {
588
+ if (error.signal === "SIGTERM" || error.code === "ETIMEDOUT") {
589
+ console.error(`hunch: git ${args.find((a) => !a.startsWith("-")) ?? args[0]} in "${hunchDir}" exceeded ${commitGitTimeoutMs()} ms and was stopped; the write stays local until the next flush`);
590
+ return false;
591
+ }
573
592
  // best-effort: nothing staged / not a repo / offline — EXCEPT a
574
593
  // stranded index.lock, which would otherwise fail every future
575
594
  // flush silently (issue #53); heal it and retry once.
@@ -623,7 +642,7 @@ export function commitAndPushHunch(hunchDir, message, opts) {
623
642
  const staged = stagedMemoryPaths(hunchDir, env, opts.push !== false);
624
643
  if (staged === null) {
625
644
  try {
626
- execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
645
+ execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env, timeout: commitGitTimeoutMs() });
627
646
  }
628
647
  catch { /* best-effort unstage */ }
629
648
  // Public-store commits (push:false) skip QUIETLY: a non-memory staged set there is
@@ -691,8 +710,9 @@ export function commitAndPushHunch(hunchDir, message, opts) {
691
710
  ...(opts.push === false ? [] : ["-c", `core.attributesFile=${gitNullDevice()}`]),
692
711
  "-c", "core.autocrlf=false",
693
712
  "-c", "commit.gpgsign=false",
713
+ "-c", "gc.auto=0",
694
714
  "commit", "--no-gpg-sign", "--only", "-m", message, "--", ...commitPaths,
695
- ], { stdio: "ignore", env, timeout: 15_000 });
715
+ ], { stdio: "ignore", env, timeout: commitGitTimeoutMs() });
696
716
  committed = true;
697
717
  }
698
718
  catch (error) {
@@ -775,7 +795,7 @@ function stagedMemoryPaths(hunchDir, env, allowMemoryDeletions = false) {
775
795
  let out = "";
776
796
  let prefix = "";
777
797
  try {
778
- prefix = execFileSync("git", ["-C", hunchDir, "rev-parse", "--show-prefix"], { encoding: "utf8", env }).trim().replace(/\\/g, "/");
798
+ prefix = execFileSync("git", ["-C", hunchDir, "rev-parse", "--show-prefix"], { encoding: "utf8", env, timeout: commitGitTimeoutMs() }).trim().replace(/\\/g, "/");
779
799
  }
780
800
  catch {
781
801
  return null;
@@ -786,7 +806,7 @@ function stagedMemoryPaths(hunchDir, env, allowMemoryDeletions = false) {
786
806
  // heuristic rename presentation so the exact paths remain independently
787
807
  // auditable against the contained-memory rules below.
788
808
  try {
789
- out = execFileSync("git", ["-C", hunchDir, "diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-status"], { encoding: "utf8", env });
809
+ out = execFileSync("git", ["-C", hunchDir, "diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-status"], { encoding: "utf8", env, timeout: commitGitTimeoutMs() });
790
810
  }
791
811
  catch {
792
812
  return null;
@@ -47,7 +47,7 @@ export function renderHunchSection(store, root) {
47
47
  lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
48
48
  lines.push("");
49
49
  lines.push("**Orient (session/task start):**");
50
- lines.push("- For a new user task, call `hunch_task(action: \"start\", title: <short task title>)` once and retain its `task_id`. If a native prompt hook already supplied a task ID, reuse its exact start arguments instead of creating another task; each new native prompt has its own ID. Otherwise reuse the ID for follow-up work on the same task; never borrow another task's ID. This is task bookkeeping; `hunch_context` remains the first memory lookup. If reporting fails, continue the work and disclose the gap.");
50
+ lines.push("- For a new user task, call `hunch_task(action: \"start\", title: <short task title>)` once and retain its `task_id`. Claude Code's prompt hook supplies a task ID natively — reuse its exact start arguments instead of creating another task (each new prompt has its own ID). Codex supplies one the same way once its `.codex/hooks.json` is trusted (`/hooks`). Hosts without prompt hooks (Windsurf, Cursor) never receive one: start the task yourself. Reuse the ID for follow-up work on the same task; never borrow another task's ID. This is task bookkeeping; `hunch_context` remains the first memory lookup. If reporting fails, continue the work and disclose the gap.");
51
51
  lines.push("- When the user asks to **update Hunch**, run `hunch update` from this repository root. It updates to the latest release and repairs all configured harness pins. Use `hunch update --global` to also update a global CLI alongside a repository dependency; reconnect active MCP sessions afterward.");
52
52
  lines.push("- `hunch_context(target, task_id)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST** for memory. Include the current task ID on each context call so its contribution is inspectable.");
53
53
  lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
@@ -10,9 +10,9 @@ export declare const HARNESSES: {
10
10
  };
11
11
  readonly codex: {
12
12
  readonly mcp: ".codex/config.toml";
13
- readonly hooks: "";
14
- readonly key: "";
15
- readonly events: readonly [];
13
+ readonly hooks: ".codex/hooks.json";
14
+ readonly key: "hooks";
15
+ readonly events: readonly ["SessionStart", "PreToolUse", "PostToolUse", "PreCompact"];
16
16
  };
17
17
  readonly cursor: {
18
18
  readonly mcp: ".cursor/mcp.json";
@@ -58,6 +58,11 @@ export interface IntegrationHealth {
58
58
  scope: "repository-config";
59
59
  issues: HealthIssue[];
60
60
  harnesses: HarnessHealth[];
61
+ /** Every exact Hunch pin found in repository launch config, once per file+version. */
62
+ pins: Array<{
63
+ file: string;
64
+ version: string;
65
+ }>;
61
66
  }
62
67
  export declare function readLauncher(root: string, harness: Harness): {
63
68
  command: string;
@@ -65,9 +70,18 @@ export declare function readLauncher(root: string, harness: Harness): {
65
70
  customEnvironment: boolean;
66
71
  };
67
72
  export declare function inspectIntegrations(root: string, selected?: Harness): IntegrationHealth;
73
+ /** Harness launch files git ignores: this machine's config, never the tag's. A
74
+ * release cut may keep these at the last published version (see
75
+ * tooling/sync-version-pins.mjs) so hooks and MCP never point at a version npm
76
+ * cannot serve. Unknown git state yields [] — callers then treat nothing as local. */
77
+ export declare function machineLocalIntegrationFiles(root: string): string[];
68
78
  /** Repair only exact published pins. Preserve formatting and all other values.
69
- * Preflight every affected file before writing any; reject malformed JSON/TOML. */
70
- export declare function repairIntegrationPins(root: string): string[];
79
+ * Preflight every affected file before writing any; reject malformed JSON/TOML.
80
+ * `skip` leaves a file untouched (used to keep machine-local pins on a version
81
+ * npm can actually serve while a release is still publishing). */
82
+ export declare function repairIntegrationPins(root: string, opts?: {
83
+ skip?: (file: string) => boolean;
84
+ }): string[];
71
85
  export declare function integrationHealthFails(report: IntegrationHealth, required?: readonly Capability[]): boolean;
72
86
  export declare function formatIntegrationHealth(report: IntegrationHealth): string;
73
87
  /** Bounded session warning; diagnostics must never break hook execution. */
@@ -1,6 +1,7 @@
1
1
  /** Repository integration checks. Configuration is evidence of wiring, never
2
2
  * evidence that a host delivered context or enforced a decision. */
3
3
  import { existsSync, readFileSync, lstatSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
4
5
  import { join, resolve } from "node:path";
5
6
  import { parse as parseToml } from "smol-toml";
6
7
  import { parseJsonc } from "../core/jsonc.js";
@@ -20,7 +21,7 @@ const OBSERVATION_FRESH_MS = 30 * 86_400_000;
20
21
  export const CAPABILITIES = ["mcp", "context", "edit-blocking", "failure-capture", "compaction"];
21
22
  export const HARNESSES = {
22
23
  claude: { mcp: ".mcp.json", hooks: ".claude/settings.json", key: "mcpServers", events: ["SessionStart", "PreToolUse", "PostToolUseFailure", "PreCompact"] },
23
- codex: { mcp: ".codex/config.toml", hooks: "", key: "", events: [] },
24
+ codex: { mcp: ".codex/config.toml", hooks: ".codex/hooks.json", key: "hooks", events: ["SessionStart", "PreToolUse", "PostToolUse", "PreCompact"] },
24
25
  cursor: { mcp: ".cursor/mcp.json", hooks: ".cursor/hooks.json", key: "mcpServers", events: ["sessionStart", "preToolUse", "postToolUse", ""] },
25
26
  vscode: { mcp: ".vscode/mcp.json", hooks: ".github/hooks/hunch.json", key: "servers", events: ["SessionStart", "PreToolUse", "PostToolUse", ""] },
26
27
  windsurf: { mcp: ".windsurf/mcp_config.json", hooks: ".windsurf/hooks.json", key: "mcpServers", events: ["", "pre_write_code", "post_run_command", ""] },
@@ -102,7 +103,7 @@ function expectedVersion(root) {
102
103
  return version;
103
104
  }
104
105
  export function inspectIntegrations(root, selected) {
105
- const report = { schema: "hunch.integration-health/1", expectedVersion: HUNCH_VERSION, scope: "repository-config", issues: [], harnesses: [] };
106
+ const report = { schema: "hunch.integration-health/1", expectedVersion: HUNCH_VERSION, scope: "repository-config", issues: [], harnesses: [], pins: [] };
106
107
  try {
107
108
  report.expectedVersion = expectedVersion(root);
108
109
  }
@@ -115,7 +116,9 @@ export function inspectIntegrations(root, selected) {
115
116
  const pins = [...value.matchAll(pinPattern)];
116
117
  if (value.includes("@davesheffer/hunch") && !pins.length)
117
118
  report.issues.push({ file, code: "unpinned-package", detail: "Hunch npm launcher has no exact version; run hunch init with the intended version" });
118
- for (const [, version] of pins) {
119
+ for (const [, version = ""] of pins) {
120
+ if (!report.pins.some(p => p.file === file && p.version === version))
121
+ report.pins.push({ file, version });
119
122
  if (version !== report.expectedVersion)
120
123
  report.issues.push({ file, code: "version-drift", detail: `Hunch ${version} differs from expected ${report.expectedVersion}; run hunch integrations repair-pins` });
121
124
  }
@@ -146,7 +149,11 @@ export function inspectIntegrations(root, selected) {
146
149
  }
147
150
  let events = {};
148
151
  let disabled = false;
149
- if (spec.hooks) {
152
+ // A hooks file that was never written is a coverage gap (the adapter is
153
+ // not installed), not configuration drift: report it, never fail on it.
154
+ // `--require` still refuses, because nothing unverified counts.
155
+ const hooksAbsent = !!spec.hooks && !existsSync(join(root, spec.hooks));
156
+ if (spec.hooks && !hooksAbsent) {
150
157
  try {
151
158
  const config = object(parseJsonc(readFileSync(join(root, spec.hooks), "utf8")));
152
159
  disabled = config.disableAllHooks === true;
@@ -164,6 +171,9 @@ export function inspectIntegrations(root, selected) {
164
171
  status.status = capability === "context" ? "advisory-only" : "unsupported";
165
172
  status.detail = capability === "context" ? "Hunch relies on instructions and voluntary MCP calls on this adapter" : "No Hunch lifecycle adapter for this capability";
166
173
  }
174
+ else if (hooksAbsent) {
175
+ status.detail = `No ${spec.hooks}; run hunch init to install this host's lifecycle hooks`;
176
+ }
167
177
  else if (disabled || firmness === "off" || ((capability === "failure-capture") && process.env.HUNCH_PIPELINE === "0")) {
168
178
  status.status = "unsupported";
169
179
  status.detail = "Disabled by local hook settings, firmness, or HUNCH_PIPELINE";
@@ -201,15 +211,30 @@ export function inspectIntegrations(root, selected) {
201
211
  report.issues.push({ file: ".", code: "no-integrations", detail: "No repository integrations found; global and managed host settings are not inspected" });
202
212
  return report;
203
213
  }
214
+ /** Harness launch files git ignores: this machine's config, never the tag's. A
215
+ * release cut may keep these at the last published version (see
216
+ * tooling/sync-version-pins.mjs) so hooks and MCP never point at a version npm
217
+ * cannot serve. Unknown git state yields [] — callers then treat nothing as local. */
218
+ export function machineLocalIntegrationFiles(root) {
219
+ const files = Object.values(HARNESSES).flatMap(s => [s.mcp, s.hooks]).filter(f => f && existsSync(join(root, f)));
220
+ if (!files.length)
221
+ return [];
222
+ const r = spawnSync("git", ["check-ignore", "--", ...files], { cwd: root, encoding: "utf8", windowsHide: true });
223
+ if (r.error || (r.status !== 0 && r.status !== 1))
224
+ return [];
225
+ return (r.stdout ?? "").split(/\r?\n/).map(l => l.trim()).filter(Boolean);
226
+ }
204
227
  /** Repair only exact published pins. Preserve formatting and all other values.
205
- * Preflight every affected file before writing any; reject malformed JSON/TOML. */
206
- export function repairIntegrationPins(root) {
228
+ * Preflight every affected file before writing any; reject malformed JSON/TOML.
229
+ * `skip` leaves a file untouched (used to keep machine-local pins on a version
230
+ * npm can actually serve while a release is still publishing). */
231
+ export function repairIntegrationPins(root, opts = {}) {
207
232
  const version = expectedVersion(root);
208
233
  const pending = [];
209
234
  for (const [name, spec] of Object.entries(HARNESSES)) {
210
235
  for (const file of [spec.mcp, spec.hooks].filter(Boolean)) {
211
236
  const path = join(root, file);
212
- if (!existsSync(path))
237
+ if (!existsSync(path) || opts.skip?.(file))
213
238
  continue;
214
239
  // Never follow a config symlink or symlinked parent into another project.
215
240
  let current = resolve(root);
@@ -225,15 +250,15 @@ export function repairIntegrationPins(root) {
225
250
  return `@davesheffer/hunch@${version}`;
226
251
  });
227
252
  let after;
228
- if (name === "codex") {
253
+ if (name === "codex" && file === spec.mcp) {
229
254
  readLauncher(root, "codex");
230
255
  const block = codexBlock(before);
231
256
  const table = object(object(parseToml(block).mcp_servers).hunch);
232
- if (Object.keys(table).some(key => !["command", "args"].includes(key)))
257
+ if (Object.keys(table).some(key => !["command", "args", "startup_timeout_sec"].includes(key)))
233
258
  throw new Error(`custom managed settings require manual pin repair: ${file}`);
234
259
  // Replace only the canonical args line, never comments or another table.
235
260
  const lines = block.split("\n");
236
- if (lines.some(line => line.trim() && !line.trim().startsWith("#") && !/^\s*(?:\[mcp_servers\.hunch\]|command\s*=|args\s*=)/.test(line)))
261
+ if (lines.some(line => line.trim() && !line.trim().startsWith("#") && !/^\s*(?:\[mcp_servers\.hunch\]|command\s*=|args\s*=|startup_timeout_sec\s*=)/.test(line)))
237
262
  throw new Error(`custom managed TOML requires manual pin repair: ${file}`);
238
263
  const next = lines.map(line => /^\s*args\s*=/.test(line) ? replace(line.split("#")[0]) + (line.includes("#") ? `#${line.split("#").slice(1).join("#")}` : "") : line).join("\n");
239
264
  after = before.replace(block, next);
@@ -49,6 +49,13 @@ export declare function writeWindsurfRule(root: string, store: HunchStore): stri
49
49
  * registration remain the durable grounding path if a Cursor build suppresses
50
50
  * a hook's agent_message. */
51
51
  export declare function writeCursorHooks(root: string, inv: Invocation): string;
52
+ /** Codex CLI hooks (0.153+): `.codex/hooks.json`, the same event names, stdin
53
+ * payload, and stdout contract as Claude Code, with `turn_id` as the per-prompt
54
+ * identity and `apply_patch` as the edit tool. Project-layer hooks load only for
55
+ * a trusted project and must be trusted once in Codex (`/hooks`), which the
56
+ * scaffold output says. Codex's PreToolUse rejects `continue:false`, so the
57
+ * strict gate answers with `permissionDecision` — the shape Hunch already emits. */
58
+ export declare function writeCodexHooks(root: string, inv: Invocation): string;
52
59
  /** VS Code's native workspace hook location. It supports all lifecycle events
53
60
  * Hunch needs and uses the same stdout contract as Claude Code, with different
54
61
  * camelCase tool fields normalized in core/agenthook.ts. */
@@ -89,6 +89,10 @@ function hookCommand(inv, provider) {
89
89
  }
90
90
  function isHunchProviderHook(entry) {
91
91
  const e = entry && typeof entry === "object" ? entry : null;
92
+ // Codex (like Claude Code) nests commands under a matcher entry: an entry
93
+ // whose every nested hook is ours is ours; a mixed entry stays foreign.
94
+ if (e && Array.isArray(e.hooks) && e.hooks.length && typeof e.command !== "string")
95
+ return e.hooks.every(isHunchProviderHook);
92
96
  const command = typeof e?.command === "string" ? e.command : "";
93
97
  // Anchored to the shapes hookCommand() writes — a Hunch launcher (the pinned
94
98
  // npm package spec, or a …/index.js|ts path for source installs) plus a tail
@@ -189,6 +193,8 @@ export function writeCodexConfig(root, inv) {
189
193
  "[mcp_servers.hunch]",
190
194
  `command = ${tomlStr(inv.command)}`,
191
195
  `args = [${argsToml}]`,
196
+ "# A cold npx install can exceed Codex's 10 s default; a slow start must not drop Hunch from the tool catalog.",
197
+ "startup_timeout_sec = 60",
192
198
  TOML_END,
193
199
  ].join("\n");
194
200
  // Strip any prior managed block first, so `base` is the user's own TOML.
@@ -286,6 +292,26 @@ export function writeCursorHooks(root, inv) {
286
292
  }
287
293
  return written;
288
294
  }
295
+ /** Codex CLI hooks (0.153+): `.codex/hooks.json`, the same event names, stdin
296
+ * payload, and stdout contract as Claude Code, with `turn_id` as the per-prompt
297
+ * identity and `apply_patch` as the edit tool. Project-layer hooks load only for
298
+ * a trusted project and must be trusted once in Codex (`/hooks`), which the
299
+ * scaffold output says. Codex's PreToolUse rejects `continue:false`, so the
300
+ * strict gate answers with `permissionDecision` — the shape Hunch already emits. */
301
+ export function writeCodexHooks(root, inv) {
302
+ const file = join(root, ".codex", "hooks.json");
303
+ const command = hookCommand(inv, "codex");
304
+ const entry = (matcher) => ({ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command }] });
305
+ return writeHookConfig(file, {
306
+ SessionStart: [entry()],
307
+ UserPromptSubmit: [entry()],
308
+ PreToolUse: [entry("apply_patch")],
309
+ PostToolUse: [entry("apply_patch|shell|local_shell")],
310
+ Stop: [entry()],
311
+ PreCompact: [entry()],
312
+ SubagentStart: [entry()],
313
+ });
314
+ }
289
315
  /** VS Code's native workspace hook location. It supports all lifecycle events
290
316
  * Hunch needs and uses the same stdout contract as Claude Code, with different
291
317
  * camelCase tool fields normalized in core/agenthook.ts. */
@@ -470,7 +496,7 @@ export function scaffoldProviders(root, inv, store, options = {}) {
470
496
  const tasks = [
471
497
  ["Cursor", () => runProvider([() => writeCursorMcp(root, inv), () => writeCursorRule(root, store), ...(hooks ? [() => writeCursorHooks(root, inv)] : [])])],
472
498
  ["VS Code (Copilot)", () => runProvider([() => writeVscodeMcp(root, inv), () => writeCopilotInstructions(root, store), ...(hooks ? [() => writeVscodeHooks(root, inv)] : [])])],
473
- ["Codex CLI", () => runProvider([() => writeCodexConfig(root, inv)])],
499
+ ["Codex CLI", () => runProvider([() => writeCodexConfig(root, inv), ...(hooks ? [() => writeCodexHooks(root, inv)] : [])])],
474
500
  ["Windsurf", () => {
475
501
  return runProvider([
476
502
  () => writeWindsurfMcp(root, inv),
@@ -0,0 +1,15 @@
1
+ export type PublishedStatus = "published" | "unpublished" | "unknown";
2
+ export interface NpmResult {
3
+ status: number | null;
4
+ stdout: string;
5
+ stderr: string;
6
+ error?: Error;
7
+ }
8
+ export type NpmRunner = (args: string[]) => NpmResult;
9
+ export declare function defaultNpmRunner(timeoutMs: number): NpmRunner;
10
+ /** Whether npm can serve `@davesheffer/hunch@<version>`. "unknown" covers offline,
11
+ * timeouts, and unexpected output; callers must never read it as unpublished. */
12
+ export declare function publishedStatus(version: string, opts?: {
13
+ timeoutMs?: number;
14
+ run?: NpmRunner;
15
+ }): PublishedStatus;
@@ -0,0 +1,41 @@
1
+ /** Bounded npm registry lookups that never throw. A pin npm cannot serve makes
2
+ * every `npx --package=…` launcher (hooks and MCP) fail before Hunch runs, and
3
+ * the hosts report nothing — so the CLI must be able to name that state. */
4
+ import { spawnSync } from "node:child_process";
5
+ const exactVersion = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
6
+ export function defaultNpmRunner(timeoutMs) {
7
+ return (args) => {
8
+ const windows = process.platform === "win32";
9
+ const r = spawnSync(windows ? `npm ${args.join(" ")}` : "npm", windows ? [] : args, {
10
+ shell: windows, windowsHide: true, encoding: "utf8", timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"],
11
+ });
12
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", error: r.error };
13
+ };
14
+ }
15
+ /** Whether npm can serve `@davesheffer/hunch@<version>`. "unknown" covers offline,
16
+ * timeouts, and unexpected output; callers must never read it as unpublished. */
17
+ export function publishedStatus(version, opts = {}) {
18
+ if (!exactVersion.test(version))
19
+ return "unknown";
20
+ const run = opts.run ?? defaultNpmRunner(opts.timeoutMs ?? 8000);
21
+ let r;
22
+ try {
23
+ r = run(["view", `@davesheffer/hunch@${version}`, "version", "--json"]);
24
+ }
25
+ catch {
26
+ return "unknown";
27
+ }
28
+ if (r.error)
29
+ return "unknown";
30
+ if (r.status === 0) {
31
+ try {
32
+ const v = JSON.parse(r.stdout.trim() || "null");
33
+ return v === version || (Array.isArray(v) && v.includes(version)) ? "published" : "unknown";
34
+ }
35
+ catch {
36
+ return "unknown";
37
+ }
38
+ }
39
+ return /\b(?:ETARGET|E404|notarget)\b/.test(r.stderr) ? "unpublished" : "unknown";
40
+ }
41
+ //# sourceMappingURL=registry.js.map
@@ -25,6 +25,10 @@ export interface RootControlOptions {
25
25
  /** Serve exactly `initialRoot`; never re-home to client roots or `cwd` hints. */
26
26
  pinned?: boolean;
27
27
  }
28
+ /** Delivered to every MCP client at initialize — the one grounding channel that
29
+ * needs no host hook or instruction file. Host-neutral by design (con_e04226bd05);
30
+ * per-host prose (CLAUDE.md, AGENTS.md) and hooks add to it, never replace it. */
31
+ export declare const MCP_INSTRUCTIONS: string;
28
32
  export declare function buildServerWithRootControl(initialRoot: string, options?: RootControlOptions): RootControlledServer;
29
33
  /** Back-compatible server construction for tests and callers that do not need
30
34
  * to drive roots directly. The server still owns and closes its active store. */