@davesheffer/hunch 1.32.3 → 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.
@@ -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. */