@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 +2 -2
- package/dist/cli/index.js +24 -1
- package/dist/extractors/git.js +77 -8
- package/dist/integrations/providers.js +35 -0
- package/dist/store/hunchStore.js +26 -9
- package/dist/synthesis/synthesize.js +7 -2
- package/package.json +1 -1
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 &
|
|
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();
|
package/dist/extractors/git.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
51
|
+
rmSync(lock, { recursive: true, force: true });
|
|
32
52
|
}
|
|
33
|
-
catch { /* best-effort
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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]) => {
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
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.",
|