@davesheffer/hunch 0.14.1 → 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 +11 -0
- package/dist/cli/index.js +35 -1
- package/dist/integrations/claudeConfig.js +171 -0
- package/package.json +1 -1
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
|
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
|
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.",
|