@davesheffer/hunch 0.30.0 β†’ 0.32.0

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
@@ -18,7 +18,7 @@ cd your-repo && hunch init && hunch backfill --since 90d
18
18
  hunch why src/some/file.ts # …or just ask Claude Code: "why is X built this way?"
19
19
  ```
20
20
 
21
- <sub>Works with **Claude Code, Cursor, Copilot & Windsurf** from one shared graph.</sub>
21
+ <sub>Works with **Claude Code, Cursor, Copilot, Windsurf & Google Antigravity** from one shared graph.</sub>
22
22
 
23
23
  ### πŸ“š **[Read the full documentation β†’ hunch-pi.vercel.app/docs](https://hunch-pi.vercel.app/docs)**
24
24
 
@@ -96,7 +96,7 @@ hunch why src/auth/session.ts # …then ask your assistant: "why is X buil
96
96
 
97
97
  `hunch init` scaffolds `.hunch/`, indexes the repo, installs the git hooks + merge driver,
98
98
  writes `.mcp.json` + slash commands + an auto-maintained `CLAUDE.md`, and wires up **every
99
- detected assistant** (Claude Code, Cursor, VS Code/Copilot, Windsurf, Codex) to the same
99
+ detected assistant** (Claude Code, Cursor, VS Code/Copilot, Windsurf, Codex, Google Antigravity) to the same
100
100
  graph β€” merging idempotently into existing files. **Reload your assistant in the repo**
101
101
  afterward to pick up the `hunch_*` tools. Each teammate runs `hunch init` once; the
102
102
  `.hunch/` content is shared via git.
package/dist/cli/index.js CHANGED
@@ -28,7 +28,7 @@ import { indexRepo } from "../extractors/indexer.js";
28
28
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
29
29
  import { parseTestReport } from "../extractors/testreport.js";
30
30
  import { selectProvider } from "../synthesis/provider.js";
31
- import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, gitUntrackCached } from "../extractors/git.js";
31
+ import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree } from "../extractors/git.js";
32
32
  import { runbookId, decisionId } from "../core/ids.js";
33
33
  import { extractInlineIntent } from "../extractors/comments.js";
34
34
  import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
@@ -344,6 +344,18 @@ program
344
344
  const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
345
345
  writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored, autoCommit: !!opts.autoCommit }, null, 2) + "\n");
346
346
  ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
347
+ // Also register the overlay at the SHARED git common dir, so EVERY worktree of this repo
348
+ // (current + future, any branch) auto-discovers the same memory with zero per-worktree
349
+ // setup. Stored ABSOLUTE β€” a linked worktree resolves relative paths from its OWN root, so
350
+ // only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
351
+ const commonDir = gitCommonDir(root);
352
+ let worktreeNote = "";
353
+ if (commonDir) {
354
+ const sharedDir = join(commonDir, "hunch");
355
+ mkdirSync(sharedDir, { recursive: true });
356
+ writeFileAtomic(join(sharedDir, "local.json"), JSON.stringify({ privateDir: hunchDir, autoCommit: !!opts.autoCommit }, null, 2) + "\n");
357
+ worktreeNote = ` βœ“ registered in the git common dir β€” shared by every worktree of this repo, on any branch\n`;
358
+ }
347
359
  // 4) route post-commit synthesis to the overlay (local hook, never committed)
348
360
  let hookNote = "";
349
361
  if (opts.hook && isGitRepo(root)) {
@@ -379,6 +391,7 @@ program
379
391
  }
380
392
  console.log(`βœ“ private overlay enabled β†’ ${hunchDir}\n` +
381
393
  ` βœ“ recorded in .hunch/local.json (gitignored) β€” auto-detected, no env var or shell-profile edit\n` +
394
+ worktreeNote +
382
395
  hookNote +
383
396
  migrateNote +
384
397
  ` record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n` +
@@ -1467,6 +1480,16 @@ program
1467
1480
  console.log(store.privateDir
1468
1481
  ? `private: on β†’ ${store.privateDir} (local overlay β€” unioned into queries; never committed or posted publicly)`
1469
1482
  : dim(`private: off β€” run \`hunch private\` to keep sensitive memory in a separate repo (or set HUNCH_PRIVATE_DIR)`));
1483
+ // Worktree posture: linked worktrees share ONE memory via the git common dir. Only
1484
+ // surfaced in a linked worktree (no noise in a normal single checkout), so a
1485
+ // "memory missing here" symptom has an obvious cause + fix.
1486
+ if (isLinkedWorktree(root)) {
1487
+ const common = gitCommonDir(root);
1488
+ const sharedPtr = !!common && existsSync(join(common, "hunch", "local.json"));
1489
+ console.log(store.privateDir
1490
+ ? `worktree: linked β€” sharing the repo's memory${sharedPtr ? " via the git common dir" : ""}`
1491
+ : dim(`worktree: linked, but no overlay resolved here β€” run \`hunch private\` once (any worktree) so all worktrees share it`));
1492
+ }
1470
1493
  // Semantic search is opt-in and local. Report availability + coverage without
1471
1494
  // loading the model (selectEmbedder only probes; embeddingStats just counts rows).
1472
1495
  const emb = await selectEmbedder();
@@ -1,6 +1,8 @@
1
1
  /** Deterministic git introspection for the extractor + learning loop.
2
2
  * No LLM here β€” just parsing what git already knows. */
3
3
  import { execFileSync } from "node:child_process";
4
+ import { isAbsolute, resolve, join } from "node:path";
5
+ import { mkdirSync, rmSync, statSync, realpathSync } from "node:fs";
4
6
  function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
5
7
  // stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
6
8
  return execFileSync("git", args, {
@@ -25,16 +27,50 @@ export function isGitRepo(cwd) {
25
27
  * (no recursion). Stages with a pathspec scoped to `hunchDir`, so it never sweeps unrelated
26
28
  * working-tree changes. Never throws β€” a non-repo dir / offline push just no-ops. */
27
29
  export function commitAndPushHunch(hunchDir, message) {
28
- const env = { ...process.env, HUNCH_SYNC: "1" };
29
- const run = (args) => {
30
+ // Serialize across worktrees: several worktrees auto-committing the SAME overlay repo
31
+ // at once would race git's index.lock. An atomic-mkdir lock lets one proceed; the others
32
+ // skip β€” safe because each record is already written to disk, so `git add .` here sweeps
33
+ // up anything a skipped run left pending (eventually-consistent, never lost).
34
+ const lock = join(hunchDir, ".hunch-commit.lock");
35
+ if (!acquireCommitLock(lock))
36
+ return;
37
+ try {
38
+ const env = { ...process.env, HUNCH_SYNC: "1" };
39
+ const run = (args) => {
40
+ try {
41
+ execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
42
+ }
43
+ catch { /* best-effort: nothing staged / not a repo / offline */ }
44
+ };
45
+ run(["add", "--", "."]);
46
+ run(["commit", "-m", message]);
47
+ run(["push"]);
48
+ }
49
+ finally {
30
50
  try {
31
- execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
51
+ rmSync(lock, { recursive: true, force: true });
32
52
  }
33
- catch { /* best-effort: nothing staged / not a repo / offline */ }
34
- };
35
- run(["add", "--", "."]);
36
- run(["commit", "-m", message]);
37
- run(["push"]);
53
+ catch { /* released best-effort */ }
54
+ }
55
+ }
56
+ /** Atomic mkdir lock; reclaims a stale lock (a crashed holder) older than 60s. Returns
57
+ * false when another live holder owns it β€” the caller skips rather than blocks. */
58
+ function acquireCommitLock(lock) {
59
+ try {
60
+ mkdirSync(lock);
61
+ return true;
62
+ }
63
+ catch {
64
+ try {
65
+ if (Date.now() - statSync(lock).mtimeMs > 60_000) {
66
+ rmSync(lock, { recursive: true, force: true });
67
+ mkdirSync(lock);
68
+ return true;
69
+ }
70
+ }
71
+ catch { /* lock vanished or races another reclaimer β€” treat as held */ }
72
+ return false;
73
+ }
38
74
  }
39
75
  export function headSha(cwd) {
40
76
  return gitSafe(["rev-parse", "HEAD"], cwd);
@@ -65,6 +101,39 @@ export function hooksDir(cwd) {
65
101
  export function gitDir(cwd) {
66
102
  return gitSafe(["rev-parse", "--git-dir"], cwd) || ".git";
67
103
  }
104
+ /** The SHARED git dir for the repo β€” identical across ALL linked worktrees (unlike
105
+ * `gitDir`, which is per-worktree). Absolute, so callers can anchor worktree-shared
106
+ * state (the private-overlay pointer) at one stable place. "" when not a git repo. */
107
+ export function gitCommonDir(cwd) {
108
+ const p = gitSafe(["rev-parse", "--git-common-dir"], cwd);
109
+ if (!p)
110
+ return "";
111
+ return isAbsolute(p) ? p : resolve(cwd, p);
112
+ }
113
+ /** True when `cwd` is inside a LINKED worktree (not the main checkout): its own git
114
+ * dir differs from the shared common dir. Used by `hunch doctor` and setup messaging. */
115
+ export function isLinkedWorktree(cwd) {
116
+ const common = gitCommonDir(cwd);
117
+ const own = gitSafe(["rev-parse", "--absolute-git-dir"], cwd);
118
+ if (!common || !own)
119
+ return false;
120
+ // realpath BOTH before comparing: `--absolute-git-dir` is symlink-resolved while
121
+ // gitCommonDir is not, so on macOS the main checkout would otherwise mismatch on
122
+ // /var vs /private/var and falsely read as "linked".
123
+ const norm = (p) => { try {
124
+ return realpathSync(p);
125
+ }
126
+ catch {
127
+ return resolve(p);
128
+ } };
129
+ return norm(own) !== norm(common);
130
+ }
131
+ /** Current branch name (e.g. "main", "feat/x"), or "" in detached HEAD / non-repo.
132
+ * Stamped onto auto-captured decisions so branch-scoped work stays filterable. */
133
+ export function currentBranch(cwd) {
134
+ const b = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
135
+ return b === "HEAD" ? "" : b; // detached HEAD reports "HEAD" β€” treat as no branch
136
+ }
68
137
  /** Files changed in a single commit. `--root` makes the initial commit (which
69
138
  * has no parent) report its files as additions instead of returning nothing. */
70
139
  export function commitFiles(sha, cwd) {
@@ -18,6 +18,7 @@
18
18
  * and is idempotent, so re-running `hunch init` is safe.
19
19
  */
20
20
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
21
+ import { homedir } from "node:os";
21
22
  import { join, dirname } from "node:path";
22
23
  import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
23
24
  /** Strip // line and block comments + trailing commas (JSONC β†’ JSON). String-aware
@@ -153,6 +154,37 @@ export function writeVscodeMcp(root, inv) {
153
154
  json.servers.hunch = { type: "stdio", command: inv.command, args: [...inv.args, "mcp"] };
154
155
  return writeJson(file, json);
155
156
  }
157
+ /** Google Antigravity's MCP config is GLOBAL (user home), not project-local β€” and the
158
+ * dir moved between versions (`antigravity/` vs `config/`). Resolve adaptively: an
159
+ * existing config wins, else an existing parent dir, else null (Antigravity not
160
+ * installed β€” we never create a global config for an absent tool). `home` is injectable
161
+ * for tests so we never touch the real ~/.gemini. */
162
+ export function antigravityMcpFile(home = homedir()) {
163
+ const candidates = [
164
+ join(home, ".gemini", "antigravity", "mcp_config.json"),
165
+ join(home, ".gemini", "config", "mcp_config.json"),
166
+ ];
167
+ for (const c of candidates)
168
+ if (existsSync(c))
169
+ return c;
170
+ for (const c of candidates)
171
+ if (existsSync(dirname(c)))
172
+ return c;
173
+ return null;
174
+ }
175
+ /** Antigravity: merge the hunch stdio server into the global mcp_config.json β€” same
176
+ * `mcpServers` { command, args } shape as Cursor/Claude (stdio; `serverUrl` is only for
177
+ * HTTP servers). Returns null when Antigravity isn't detected. Grounding needs nothing
178
+ * extra: Antigravity reads the project-root AGENTS.md Hunch already writes. */
179
+ export function writeAntigravityMcp(inv, home = homedir()) {
180
+ const file = antigravityMcpFile(home);
181
+ if (!file)
182
+ return null;
183
+ const json = readJsonObj(file);
184
+ json.mcpServers = json.mcpServers ?? {};
185
+ json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
186
+ return writeJson(file, json);
187
+ }
156
188
  const TOML_START = "# >>> hunch mcp (managed) >>>";
157
189
  const TOML_END = "# <<< hunch mcp <<<";
158
190
  /** Codex CLI: .codex/config.toml β€” `[mcp_servers.hunch]` stdio entry. We own only
@@ -278,6 +310,9 @@ export function scaffoldProviders(root, inv, store) {
278
310
  ["VS Code (Copilot)", () => [writeVscodeMcp(root, inv), writeCopilotInstructions(root, store)]],
279
311
  ["Codex CLI", () => [writeCodexConfig(root, inv)]],
280
312
  ["Windsurf", () => [writeWindsurfMcp(root, inv), writeWindsurfRule(root, store)]],
313
+ // Antigravity reads project-root AGENTS.md for grounding (written below); its MCP
314
+ // config is global + detection-gated, so it only writes when Antigravity is installed.
315
+ ["Google Antigravity", () => { const f = writeAntigravityMcp(inv); return f ? [f] : []; }],
281
316
  ["Any (AGENTS.md)", () => [writeAgentsMd(root, store)]],
282
317
  ];
283
318
  return tasks.map(([assistant, run]) => {
@@ -18,6 +18,7 @@ import { openDb } from "./db.js";
18
18
  import { RESET_SQL, embedHash } from "./schema.js";
19
19
  import { selectEmbedder } from "./embedder.js";
20
20
  import { JsonStore } from "./jsonStore.js";
21
+ import { gitCommonDir } from "../extractors/git.js";
21
22
  import { pathMatchesGlob } from "../core/glob.js";
22
23
  import { edgeId } from "../core/ids.js";
23
24
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
@@ -57,17 +58,33 @@ export class HunchStore {
57
58
  /** The private-overlay config from the gitignored `.hunch/local.json` (per-machine,
58
59
  * never committed). Tolerant: returns {} on missing/invalid so reads never crash. */
59
60
  localConfig() {
60
- try {
61
- const f = join(this.paths.hunch, "local.json");
62
- if (!existsSync(f))
61
+ const read = (file) => {
62
+ try {
63
+ if (!existsSync(file))
64
+ return {};
65
+ const v = JSON.parse(readFileSync(file, "utf8"));
66
+ const privateDir = typeof v.privateDir === "string" && v.privateDir.trim() ? v.privateDir.trim() : undefined;
67
+ return { privateDir, autoCommit: v.autoCommit === true };
68
+ }
69
+ catch {
63
70
  return {};
64
- const v = JSON.parse(readFileSync(f, "utf8"));
65
- const privateDir = typeof v.privateDir === "string" && v.privateDir.trim() ? v.privateDir.trim() : undefined;
66
- return { privateDir, autoCommit: v.autoCommit === true };
67
- }
68
- catch {
69
- return {};
71
+ }
72
+ };
73
+ // Per-worktree pointer first (explicit / back-compat). If it names no overlay, fall back to
74
+ // the SHARED pointer in the git common dir β€” identical across ALL worktrees, so a freshly
75
+ // added worktree (whose gitignored .hunch/local.json doesn't exist yet) still auto-discovers
76
+ // the same memory. The git lookup runs ONLY when the cheap per-worktree read is empty, keeping
77
+ // it off the hot path for already-configured checkouts.
78
+ const perWorktree = read(join(this.paths.hunch, "local.json"));
79
+ if (perWorktree.privateDir)
80
+ return perWorktree;
81
+ const common = gitCommonDir(this.paths.root);
82
+ if (common) {
83
+ const shared = read(join(common, "hunch", "local.json"));
84
+ if (shared.privateDir)
85
+ return shared;
70
86
  }
87
+ return perWorktree;
71
88
  }
72
89
  /** Merged read: public βˆͺ private overlay (private wins on id collision). Every
73
90
  * QUERY / REINDEX path uses this so MCP + the guards see private memory. Public-
@@ -1,4 +1,4 @@
1
- import { commitMeta, commitDiff, headSha } from "../extractors/git.js";
1
+ import { commitMeta, commitDiff, headSha, currentBranch } from "../extractors/git.js";
2
2
  import { analyzeDiff } from "../extractors/diff.js";
3
3
  import { selectProvider, selectEnsemble, selectVerifier, verifyDecisionSafe, DeterministicProvider } from "./provider.js";
4
4
  import { decisionId, bugId, constraintId } from "../core/ids.js";
@@ -101,6 +101,11 @@ export async function syncCommit(store, root, sha, opts = {}) {
101
101
  if (draft.pruned)
102
102
  synthBits.push(`pruned=${draft.pruned}`); // the Critic's visible value
103
103
  const synthEvidence = `synth:${synthBits.join(" ")}`;
104
+ // Tag the capturing branch so branch-scoped work stays FILTERABLE in the one shared store
105
+ // (every worktree/branch writes to the same overlay β€” this keeps "what was decided on
106
+ // feature-x?" answerable without fragmenting memory per branch). Empty in detached HEAD.
107
+ const branch = currentBranch(root);
108
+ const branchTag = branch ? [`branch:${branch}`] : [];
104
109
  const components = store.json.loadAll("components");
105
110
  const relatedComponents = components
106
111
  .filter((c) => codeFiles.some((f) => c.paths.some((g) => pathMatchesGlob(f, g))))
@@ -144,7 +149,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
144
149
  provenance: {
145
150
  source: draft.source,
146
151
  confidence: draft.confidence,
147
- evidence: [`commit:${meta.shortSha}`, synthEvidence, ...codeFiles.slice(0, 8)],
152
+ evidence: [`commit:${meta.shortSha}`, synthEvidence, ...branchTag, ...codeFiles.slice(0, 8)],
148
153
  last_verified: new Date().toISOString(), // when the Hunch last re-derived this
149
154
  },
150
155
  date: meta.date, // the commit date
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "license": "Apache-2.0",
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.",