@davesheffer/hunch 0.14.0 → 0.14.2
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 +15 -2
- package/dist/cli/index.js +35 -1
- package/dist/integrations/claudeConfig.js +171 -0
- package/dist/synthesis/provider.js +15 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -85,6 +85,17 @@ Claude Code in the repo** afterward to pick up the `hunch_*` tools. (Each teamma
|
|
|
85
85
|
`hunch init` once to wire up their own clone; the captured `.hunch/` content is shared
|
|
86
86
|
via git.)
|
|
87
87
|
|
|
88
|
+
> **Prefer `hunch init` (project-local `.mcp.json`) over a global `claude mcp add`.**
|
|
89
|
+
> `.mcp.json` is registered by file path, so it's robust. A global `claude mcp add`
|
|
90
|
+
> writes to `~/.claude.json` keyed by the raw working-directory string — and **on
|
|
91
|
+
> Windows** that's a trap: drive letters are case-insensitive (`c:\` and `C:\` are the
|
|
92
|
+
> same folder) but Claude Code compares the key case-sensitively, so it can create two
|
|
93
|
+
> project blocks for one directory and a session that resolves to the *other* casing
|
|
94
|
+
> sees no `hunch_*` tools (registration looked fine, the tools just aren't there). If
|
|
95
|
+
> you hit this, run **`hunch doctor`** — on Windows it detects the split and heals it
|
|
96
|
+
> (merging the MCP servers across both casings, after backing up `~/.claude.json`).
|
|
97
|
+
> `hunch init` runs the same heal automatically at the end.
|
|
98
|
+
|
|
88
99
|
### 4. Use it
|
|
89
100
|
|
|
90
101
|
```bash
|
|
@@ -109,8 +120,10 @@ The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
|
|
|
109
120
|
`hunch_check_constraints`, `hunch_get_dependents` (blast radius), `hunch_blast_radius`
|
|
110
121
|
(dependent files + near-violations a change could break indirectly), `hunch_bug_lineage`,
|
|
111
122
|
`hunch_context` (surgical minimal slice for a task), `hunch_timeline` (a target's decision
|
|
112
|
-
history over time), `
|
|
113
|
-
|
|
123
|
+
history over time), `hunch_merge_verdict` (a cited BLOCK/WARN/PASS over a diff — see below),
|
|
124
|
+
and the write-backs `hunch_record_decision`, `hunch_record_correction` (turn a correction into
|
|
125
|
+
an enforced invariant — see below). `hunch_why` and `hunch_context` take an optional `as_of`
|
|
126
|
+
(commit/tag/branch) to **time-travel** the graph to a past state.
|
|
114
127
|
|
|
115
128
|
### Works with any MCP assistant
|
|
116
129
|
|
package/dist/cli/index.js
CHANGED
|
@@ -34,6 +34,7 @@ import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
|
34
34
|
import { updateClaudeMd } from "../integrations/claudemd.js";
|
|
35
35
|
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
36
36
|
import { scaffoldProviders } from "../integrations/providers.js";
|
|
37
|
+
import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
|
|
37
38
|
import { formatContext } from "../core/format.js";
|
|
38
39
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
39
40
|
import { blockingInScope } from "../core/hookpolicy.js";
|
|
@@ -106,7 +107,10 @@ program
|
|
|
106
107
|
console.log(" ⚠ not a git repo — skipped hooks (run `git init` to enable the learning loop)");
|
|
107
108
|
}
|
|
108
109
|
const mcp = writeMcpJson(root, inv.mcp);
|
|
109
|
-
|
|
110
|
+
// .mcp.json is the CANONICAL registration: Claude Code resolves it by file path,
|
|
111
|
+
// so it's immune to the Windows ~/.claude.json drive-letter case-split that a
|
|
112
|
+
// global `claude mcp add` is prone to (see `hunch doctor`).
|
|
113
|
+
console.log(` ✓ wrote ${rel(root, mcp)} (registers the Hunch MCP server — canonical, path-keyed; prefer over a global \`claude mcp add\`)`);
|
|
110
114
|
const cmds = writeSlashCommands(root);
|
|
111
115
|
console.log(` ✓ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
|
|
112
116
|
const cmd = updateClaudeMd(root, store);
|
|
@@ -131,6 +135,10 @@ program
|
|
|
131
135
|
if (p.error)
|
|
132
136
|
console.log(` ⚠ skipped ${p.assistant}: ${p.error}`);
|
|
133
137
|
}
|
|
138
|
+
// Windows self-heal: if an earlier global `claude mcp add` left a drive-letter
|
|
139
|
+
// case-split in ~/.claude.json, merge it so hunch resolves under either casing.
|
|
140
|
+
// No-op (silent) off Windows.
|
|
141
|
+
reportClaudeConfigHeal();
|
|
134
142
|
store.close();
|
|
135
143
|
console.log("\nNext: make a commit (the hook captures a decision), then ask your coding assistant \"why is X built this way?\"");
|
|
136
144
|
console.log("Cold start? Seed from history: hunch backfill --since 90d");
|
|
@@ -940,11 +948,37 @@ program
|
|
|
940
948
|
else {
|
|
941
949
|
console.log(dim(`semantic: off (keyword search only) — enable: npm i -g @huggingface/transformers && hunch embed`));
|
|
942
950
|
}
|
|
951
|
+
// Windows: detect/heal the Claude Code ~/.claude.json drive-letter case-split
|
|
952
|
+
// that silently hides the hunch_* MCP tools. No-op (silent) off Windows.
|
|
953
|
+
reportClaudeConfigHeal();
|
|
943
954
|
store.close();
|
|
944
955
|
});
|
|
945
956
|
function rel(root, p) {
|
|
946
957
|
return p.startsWith(root) ? p.slice(root.length + 1) : p;
|
|
947
958
|
}
|
|
959
|
+
/** Run the Windows ~/.claude.json drive-letter case-split heal and print what it
|
|
960
|
+
* did. Silent + no-op off Windows. A parse refusal is surfaced as a warning, never
|
|
961
|
+
* thrown out of doctor/init (those commands must still complete). */
|
|
962
|
+
function reportClaudeConfigHeal() {
|
|
963
|
+
let res;
|
|
964
|
+
try {
|
|
965
|
+
res = healClaudeConfigCaseSplit();
|
|
966
|
+
}
|
|
967
|
+
catch (e) {
|
|
968
|
+
console.log(` ⚠ Claude config: ${e.message}`);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (!res.applicable)
|
|
972
|
+
return; // non-Windows: the case-split bug can't occur
|
|
973
|
+
if (!res.changed) {
|
|
974
|
+
console.log(dim(`Claude config: no drive-letter project split (${res.file})`));
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
for (const g of res.groups) {
|
|
978
|
+
console.log(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
|
|
979
|
+
}
|
|
980
|
+
console.log(dim(` ↳ backup: ${res.backup}`));
|
|
981
|
+
}
|
|
948
982
|
function dim(s) {
|
|
949
983
|
return `\x1b[2m${s}\x1b[0m`;
|
|
950
984
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heal a Windows-only Claude Code misconfiguration that silently hides Hunch's
|
|
3
|
+
* `hunch_*` MCP tools.
|
|
4
|
+
*
|
|
5
|
+
* THE BUG (Claude Code's, not Hunch's): Claude Code stores per-project config in
|
|
6
|
+
* `~/.claude.json` under a `projects` map keyed by the raw cwd STRING. Windows
|
|
7
|
+
* drive letters are case-insensitive (`c:\` and `C:\` are the same directory) but
|
|
8
|
+
* Claude Code compares the key case-sensitively. So it can create TWO project
|
|
9
|
+
* blocks for one real directory:
|
|
10
|
+
*
|
|
11
|
+
* "c:/Users/me/repo" -> mcpServers: {} (what one session resolves to)
|
|
12
|
+
* "C:/Users/me/repo" -> mcpServers: { hunch: {…} } (where `claude mcp add` wrote)
|
|
13
|
+
*
|
|
14
|
+
* A session whose cwd resolves to the OTHER casing reads the empty block → no
|
|
15
|
+
* hunch tools, even though registration "succeeded".
|
|
16
|
+
*
|
|
17
|
+
* Only the GLOBAL `claude mcp add` route (cwd-string-keyed in ~/.claude.json) is
|
|
18
|
+
* fragile. Hunch's own project-local `.mcp.json` (scaffold.ts writeMcpJson) is
|
|
19
|
+
* IMMUNE — Claude resolves it by file path, not by a cwd string key.
|
|
20
|
+
*/
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { existsSync, readFileSync, copyFileSync } from "node:fs";
|
|
24
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
25
|
+
/** Absolute path to Claude Code's per-user config (`~/.claude.json`). */
|
|
26
|
+
export function claudeConfigPath() {
|
|
27
|
+
return join(homedir(), ".claude.json");
|
|
28
|
+
}
|
|
29
|
+
/** Group key for two project keys that point at the SAME real directory. The bug
|
|
30
|
+
* is purely drive-letter case (+ slash style), so we normalize ONLY those — never
|
|
31
|
+
* the rest of the path — so genuinely distinct projects are never merged. */
|
|
32
|
+
function normalizeProjectKey(key) {
|
|
33
|
+
return key.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_m, d) => `${d.toLowerCase()}:`);
|
|
34
|
+
}
|
|
35
|
+
function isPlainObject(v) {
|
|
36
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
37
|
+
}
|
|
38
|
+
function asStringArray(v) {
|
|
39
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
40
|
+
}
|
|
41
|
+
/** Union the MCP config across all casing variants of one real project. First-wins
|
|
42
|
+
* on a server-name collision (keys iterated in sorted order for determinism) so we
|
|
43
|
+
* never clobber an existing server definition; enabled/disabled lists are deduped
|
|
44
|
+
* unions. */
|
|
45
|
+
function unionConfig(blocks) {
|
|
46
|
+
const servers = {};
|
|
47
|
+
const enabled = new Set();
|
|
48
|
+
const disabled = new Set();
|
|
49
|
+
for (const b of blocks) {
|
|
50
|
+
if (isPlainObject(b.mcpServers)) {
|
|
51
|
+
for (const [name, cfg] of Object.entries(b.mcpServers))
|
|
52
|
+
if (!(name in servers))
|
|
53
|
+
servers[name] = cfg;
|
|
54
|
+
}
|
|
55
|
+
for (const s of asStringArray(b.enabledMcpjsonServers))
|
|
56
|
+
enabled.add(s);
|
|
57
|
+
for (const s of asStringArray(b.disabledMcpjsonServers))
|
|
58
|
+
disabled.add(s);
|
|
59
|
+
}
|
|
60
|
+
return { servers, enabled: [...enabled], disabled: [...disabled] };
|
|
61
|
+
}
|
|
62
|
+
/** Mirror the union into one casing block, touching ONLY the three MCP keys and
|
|
63
|
+
* ADDING missing entries (never overwriting an existing one). Returns true if the
|
|
64
|
+
* block changed. */
|
|
65
|
+
function applyUnion(block, u) {
|
|
66
|
+
let changed = false;
|
|
67
|
+
if (!isPlainObject(block.mcpServers)) {
|
|
68
|
+
block.mcpServers = {};
|
|
69
|
+
if (Object.keys(u.servers).length)
|
|
70
|
+
changed = true;
|
|
71
|
+
}
|
|
72
|
+
for (const [name, cfg] of Object.entries(u.servers)) {
|
|
73
|
+
if (!(name in block.mcpServers)) {
|
|
74
|
+
block.mcpServers[name] = cfg;
|
|
75
|
+
changed = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const mergeList = (key, extra) => {
|
|
79
|
+
if (!extra.length)
|
|
80
|
+
return;
|
|
81
|
+
const cur = asStringArray(block[key]);
|
|
82
|
+
const merged = [...new Set([...cur, ...extra])];
|
|
83
|
+
if (merged.length !== cur.length) {
|
|
84
|
+
block[key] = merged;
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
mergeList("enabledMcpjsonServers", u.enabled);
|
|
89
|
+
mergeList("disabledMcpjsonServers", u.disabled);
|
|
90
|
+
return changed;
|
|
91
|
+
}
|
|
92
|
+
/** Windows-safe timestamp for the backup filename (no `:` — invalid on NTFS). */
|
|
93
|
+
function backupStamp() {
|
|
94
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Scan `~/.claude.json` for project keys that collapse to the same real directory
|
|
98
|
+
* but differ by drive-letter case, and HEAL each split by computing the UNION of
|
|
99
|
+
* its casings' MCP config and MIRRORING that union back into EVERY casing.
|
|
100
|
+
*
|
|
101
|
+
* Why mirror (not merge-into-one-canonical-and-delete-the-rest): we cannot predict
|
|
102
|
+
* which casing a given Claude Code session will resolve its cwd to. If we collapsed
|
|
103
|
+
* to a single canonical key, a session that lands on a deleted casing would get a
|
|
104
|
+
* fresh empty block → hunch missing again. Mirroring the union guarantees that
|
|
105
|
+
* whichever casing wins, the server is there — and it deletes nothing Claude made.
|
|
106
|
+
*
|
|
107
|
+
* Safety: no-op on non-Windows; backs up the file (timestamped copy) BEFORE any
|
|
108
|
+
* write; merges only (never clobbers other servers/keys); and THROWS rather than
|
|
109
|
+
* overwrite a non-empty file it cannot parse (mirrors readJsonObj in providers.ts).
|
|
110
|
+
*/
|
|
111
|
+
export function healClaudeConfigCaseSplit(opts = {}) {
|
|
112
|
+
const platform = opts.platform ?? process.platform;
|
|
113
|
+
const file = opts.file ?? claudeConfigPath();
|
|
114
|
+
const base = { platform, applicable: platform === "win32", file, changed: false, groups: [] };
|
|
115
|
+
if (platform !== "win32")
|
|
116
|
+
return base; // the case-split bug is Windows-only
|
|
117
|
+
if (!existsSync(file))
|
|
118
|
+
return base;
|
|
119
|
+
const raw = readFileSync(file, "utf8");
|
|
120
|
+
if (!raw.trim())
|
|
121
|
+
return base;
|
|
122
|
+
let root;
|
|
123
|
+
try {
|
|
124
|
+
const v = JSON.parse(raw);
|
|
125
|
+
if (!isPlainObject(v))
|
|
126
|
+
throw new Error("not a JSON object");
|
|
127
|
+
root = v;
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
throw new Error(`refusing to modify ${file}: could not parse it (${e.message}). Fix or remove it, then re-run.`);
|
|
131
|
+
}
|
|
132
|
+
const projects = root.projects;
|
|
133
|
+
if (!isPlainObject(projects))
|
|
134
|
+
return base; // nothing to heal
|
|
135
|
+
// Bucket the raw project keys by their normalized real path.
|
|
136
|
+
const buckets = new Map();
|
|
137
|
+
for (const key of Object.keys(projects)) {
|
|
138
|
+
const norm = normalizeProjectKey(key);
|
|
139
|
+
const arr = buckets.get(norm) ?? [];
|
|
140
|
+
arr.push(key);
|
|
141
|
+
buckets.set(norm, arr);
|
|
142
|
+
}
|
|
143
|
+
const groups = [];
|
|
144
|
+
let changed = false;
|
|
145
|
+
for (const [norm, keys] of buckets) {
|
|
146
|
+
if (keys.length < 2)
|
|
147
|
+
continue; // no casing split for this directory
|
|
148
|
+
keys.sort(); // deterministic first-wins union
|
|
149
|
+
const blocks = keys.map((k) => (isPlainObject(projects[k]) ? projects[k] : {}));
|
|
150
|
+
const u = unionConfig(blocks);
|
|
151
|
+
let groupChanged = false;
|
|
152
|
+
for (const k of keys) {
|
|
153
|
+
if (!isPlainObject(projects[k]))
|
|
154
|
+
projects[k] = {};
|
|
155
|
+
if (applyUnion(projects[k], u))
|
|
156
|
+
groupChanged = true;
|
|
157
|
+
}
|
|
158
|
+
if (groupChanged) {
|
|
159
|
+
changed = true;
|
|
160
|
+
groups.push({ realPath: norm, casings: keys, servers: Object.keys(u.servers) });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!changed)
|
|
164
|
+
return { ...base, groups };
|
|
165
|
+
// Back up the exact original bytes BEFORE writing the healed config.
|
|
166
|
+
const backup = `${file}.hunch-bak-${backupStamp()}`;
|
|
167
|
+
copyFileSync(file, backup);
|
|
168
|
+
writeFileAtomic(file, JSON.stringify(root, null, 2) + "\n");
|
|
169
|
+
return { ...base, changed: true, backup, groups };
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=claudeConfig.js.map
|
|
@@ -177,6 +177,18 @@ class CliSynthProvider {
|
|
|
177
177
|
return draft;
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
|
+
// A model id comes from a HUNCH_*_MODEL env var and ends up as an argv token that,
|
|
181
|
+
// on Windows, pexecIn joins into the cmd.exe line (shell:true, to resolve the npm
|
|
182
|
+
// `.cmd` shim). The untrusted prompt always travels via stdin — never argv — so the
|
|
183
|
+
// ONLY non-literal token reaching that shell line is this model id. Reject anything
|
|
184
|
+
// with whitespace or a cmd.exe metacharacter so a poisoned env var can't smuggle
|
|
185
|
+
// `& evil.exe` into the line; fall back to the provider default rather than crash on
|
|
186
|
+
// a mere typo. (pexecIn itself stays general — callers may legitimately pass a
|
|
187
|
+
// pre-quoted path token — so the guard lives here, at the untrusted-input source.)
|
|
188
|
+
const MODEL_RE = /^[A-Za-z0-9._:/-]+$/;
|
|
189
|
+
export function safeModel(v, fallback) {
|
|
190
|
+
return v && MODEL_RE.test(v) ? v : fallback;
|
|
191
|
+
}
|
|
180
192
|
// --------------------------------------------------------------------------
|
|
181
193
|
// Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
|
|
182
194
|
// --------------------------------------------------------------------------
|
|
@@ -184,7 +196,7 @@ class ClaudeCliProvider extends CliSynthProvider {
|
|
|
184
196
|
name = "claude-cli";
|
|
185
197
|
// Default to the `haiku` alias (cheap/fast, and survives model retirements)
|
|
186
198
|
// rather than a pinned dated id; override with HUNCH_SYNTH_MODEL if needed.
|
|
187
|
-
model = process.env.HUNCH_SYNTH_MODEL
|
|
199
|
+
model = safeModel(process.env.HUNCH_SYNTH_MODEL, "haiku");
|
|
188
200
|
async available() {
|
|
189
201
|
try {
|
|
190
202
|
await pexecIn("claude", ["--version"], { timeout: 8000 });
|
|
@@ -242,7 +254,7 @@ class ClaudeCliProvider extends CliSynthProvider {
|
|
|
242
254
|
// --------------------------------------------------------------------------
|
|
243
255
|
class CodexCliProvider extends CliSynthProvider {
|
|
244
256
|
name = "codex-cli";
|
|
245
|
-
model = process.env.HUNCH_CODEX_MODEL; // omit → codex uses its configured default
|
|
257
|
+
model = safeModel(process.env.HUNCH_CODEX_MODEL, undefined); // omit → codex uses its configured default
|
|
246
258
|
async available() {
|
|
247
259
|
try {
|
|
248
260
|
await pexecIn("codex", ["--version"], { timeout: 8000 });
|
|
@@ -266,7 +278,7 @@ class CodexCliProvider extends CliSynthProvider {
|
|
|
266
278
|
// --------------------------------------------------------------------------
|
|
267
279
|
class CursorCliProvider extends CliSynthProvider {
|
|
268
280
|
name = "cursor-agent";
|
|
269
|
-
model = process.env.HUNCH_CURSOR_MODEL;
|
|
281
|
+
model = safeModel(process.env.HUNCH_CURSOR_MODEL, undefined);
|
|
270
282
|
async available() {
|
|
271
283
|
try {
|
|
272
284
|
await pexecIn("cursor-agent", ["--version"], { timeout: 8000 });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"dist/**/*.js"
|
|
21
21
|
],
|
|
22
22
|
"publishConfig": {
|
|
23
|
-
"access": "public"
|
|
23
|
+
"access": "public",
|
|
24
|
+
"provenance": true
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
|
26
27
|
"claude-code",
|