@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 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
- console.log(` ✓ wrote ${rel(root, mcp)} (registers the Hunch MCP server)`);
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.1",
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.",