@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.
- package/README.md +1 -1
- package/dist/cli/index.js +40 -7
- package/dist/cli/taskReport.js +80 -1
- package/dist/core/agenthook.d.ts +1 -1
- package/dist/core/agenthook.js +22 -3
- package/dist/core/config.d.ts +3 -0
- package/dist/core/config.js +4 -1
- package/dist/core/taskReport.d.ts +49 -0
- package/dist/core/taskReport.js +99 -0
- package/dist/core/taskReportHook.d.ts +7 -1
- package/dist/core/taskReportHook.js +18 -5
- package/dist/integrations/claudemd.js +1 -1
- package/dist/integrations/health.d.ts +19 -5
- package/dist/integrations/health.js +35 -10
- package/dist/integrations/providers.d.ts +7 -0
- package/dist/integrations/providers.js +27 -1
- package/dist/integrations/registry.d.ts +15 -0
- package/dist/integrations/registry.js +41 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +345 -322
- package/dist/mcp/toolset.d.ts +30 -0
- package/dist/mcp/toolset.js +72 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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. */
|