@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.
- package/README.md +9 -4
- 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/stateContract.d.ts +3 -0
- package/dist/core/stateContract.js +1 -0
- 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/extractors/git.js +25 -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/dist/store/stateBinding.js +16 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/extractors/git.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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`.
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|