@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 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), `hunch_record_decision` (write-back). `hunch_why` and `hunch_context`
113
- take an optional `as_of` (commit/tag/branch) to **time-travel** the graph to a past state.
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
- 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
@@ -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 || "haiku";
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.0",
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",