@davesheffer/hunch 0.31.0 → 0.33.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
@@ -185,6 +185,16 @@ is **OS-agnostic**: paths are stored in POSIX form and an installed Hunch regist
185
185
  server by package name, so Windows / macOS / Linux teammates share one memory without
186
186
  per-machine fixups. → [docs](https://hunch-pi.vercel.app/docs#team)
187
187
 
188
+ ### Branches & worktrees
189
+
190
+ Memory follows you across every branch and **git worktree**, with no per-worktree setup. The
191
+ private overlay is registered once at the repo's **git common dir** (shared by all worktrees), so
192
+ a fresh `git worktree add` on any branch sees the same decisions, bugs, and invariants. Create one
193
+ already wired in with **`hunch worktree <path> [-b <branch>]`**, or just run `hunch init` / `hunch
194
+ private` once and every worktree picks it up. Auto-captured decisions are tagged with their branch,
195
+ and concurrent overlay writes are serialized — so parallel worktrees never corrupt or lose memory.
196
+ `hunch doctor` confirms a worktree is sharing.
197
+
188
198
  ## Private memory (public repo, private context)
189
199
 
190
200
  Open-source your code without open-sourcing your *reasoning*. **`hunch private`** sets up a
package/dist/cli/index.js CHANGED
@@ -28,12 +28,13 @@ 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";
35
35
  import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
36
36
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
37
+ import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
37
38
  import { installMergeDriver } from "../integrations/mergeDriver.js";
38
39
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
39
40
  import { writeCiWorkflow } from "../integrations/ciAction.js";
@@ -154,6 +155,15 @@ program
154
155
  // case-split in ~/.claude.json, merge it so hunch resolves under either casing.
155
156
  // No-op (silent) off Windows.
156
157
  reportClaudeConfigHeal();
158
+ // Worktree-seamless: register any configured overlay at the git common dir so EVERY
159
+ // worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
160
+ // and note when we're initializing inside a linked worktree (memory is shared, not separate).
161
+ if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit)) {
162
+ console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
163
+ }
164
+ if (isLinkedWorktree(root)) {
165
+ console.log(` ✓ linked worktree — sharing the repo's hooks + memory (no separate setup needed)`);
166
+ }
157
167
  store.close();
158
168
  console.log("\nNext: make a commit (the hook captures a decision), then ask your coding assistant \"why is X built this way?\"");
159
169
  console.log("Cold start? Seed from history: hunch backfill --since 90d");
@@ -344,6 +354,14 @@ program
344
354
  const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
345
355
  writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored, autoCommit: !!opts.autoCommit }, null, 2) + "\n");
346
356
  ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
357
+ // Also register the overlay at the SHARED git common dir, so EVERY worktree of this repo
358
+ // (current + future, any branch) auto-discovers the same memory with zero per-worktree
359
+ // setup. Stored ABSOLUTE — a linked worktree resolves relative paths from its OWN root, so
360
+ // only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
361
+ let worktreeNote = "";
362
+ if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit)) {
363
+ worktreeNote = ` ✓ registered in the git common dir — shared by every worktree of this repo, on any branch\n`;
364
+ }
347
365
  // 4) route post-commit synthesis to the overlay (local hook, never committed)
348
366
  let hookNote = "";
349
367
  if (opts.hook && isGitRepo(root)) {
@@ -379,11 +397,53 @@ program
379
397
  }
380
398
  console.log(`✓ private overlay enabled → ${hunchDir}\n` +
381
399
  ` ✓ recorded in .hunch/local.json (gitignored) — auto-detected, no env var or shell-profile edit\n` +
400
+ worktreeNote +
382
401
  hookNote +
383
402
  migrateNote +
384
403
  ` record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n` +
385
404
  ` override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only.`);
386
405
  });
406
+ // ---- worktree (one-command worktree wired into Hunch) ----------------------
407
+ program
408
+ .command("worktree <path>")
409
+ .description("Create a git worktree already wired into Hunch — it shares this repo's memory (the private overlay), with zero per-worktree setup.")
410
+ .option("-b, --branch <name>", "create the worktree on a NEW branch")
411
+ .option("--no-share", "don't register the overlay at the git common dir (the worktree won't see private memory)")
412
+ .action((path, opts) => {
413
+ const root = findRoot();
414
+ if (!isGitRepo(root))
415
+ return fail("`hunch worktree` needs a git repo");
416
+ const dest = resolve(root, path);
417
+ if (existsSync(dest))
418
+ return fail(`path already exists: ${dest}`);
419
+ // 1) create the worktree (on a new branch if asked, else a checkout of HEAD)
420
+ const r = spawnSync("git", ["-C", root, "worktree", "add", ...(opts.branch ? ["-b", opts.branch] : []), dest], { stdio: "inherit" });
421
+ if (r.status !== 0)
422
+ return fail("git worktree add failed");
423
+ // 2) register the overlay at the SHARED git common dir so the new worktree (and every
424
+ // other) auto-discovers the same memory — also backfills pre-0.32 single-worktree setups.
425
+ const store = new HunchStore(hunchPaths(root));
426
+ const overlay = store.privateDir;
427
+ const autoCommit = store.privateAutoCommit;
428
+ store.close();
429
+ let shareNote;
430
+ if (!opts.share) {
431
+ shareNote = ` · --no-share — the worktree will NOT see private memory`;
432
+ }
433
+ else if (overlay && ensureSharedOverlayPointer(root, overlay, autoCommit)) {
434
+ shareNote = ` ✓ memory shared via the git common dir — this worktree sees the same decisions / bugs / constraints`;
435
+ }
436
+ else if (overlay) {
437
+ shareNote = ` · could not register the shared overlay pointer (no git common dir?)`;
438
+ }
439
+ else {
440
+ shareNote = ` · no private overlay configured — run \`hunch private\` to share memory across worktrees`;
441
+ }
442
+ console.log(`✓ worktree created → ${dest}${opts.branch ? ` (new branch ${opts.branch})` : ""}\n` +
443
+ `${shareNote}\n` +
444
+ ` hooks + MCP server are shared (worktree-aware) — open your assistant in the new worktree to start.\n` +
445
+ ` (needs \`hunch\` installed globally; a worktree has no node_modules of its own)`);
446
+ });
387
447
  // ---- query ----------------------------------------------------------------
388
448
  program
389
449
  .command("query")
@@ -1467,6 +1527,16 @@ program
1467
1527
  console.log(store.privateDir
1468
1528
  ? `private: on → ${store.privateDir} (local overlay — unioned into queries; never committed or posted publicly)`
1469
1529
  : dim(`private: off — run \`hunch private\` to keep sensitive memory in a separate repo (or set HUNCH_PRIVATE_DIR)`));
1530
+ // Worktree posture: linked worktrees share ONE memory via the git common dir. Only
1531
+ // surfaced in a linked worktree (no noise in a normal single checkout), so a
1532
+ // "memory missing here" symptom has an obvious cause + fix.
1533
+ if (isLinkedWorktree(root)) {
1534
+ const common = gitCommonDir(root);
1535
+ const sharedPtr = !!common && existsSync(join(common, "hunch", "local.json"));
1536
+ console.log(store.privateDir
1537
+ ? `worktree: linked — sharing the repo's memory${sharedPtr ? " via the git common dir" : ""}`
1538
+ : dim(`worktree: linked, but no overlay resolved here — run \`hunch private\` once (any worktree) so all worktrees share it`));
1539
+ }
1470
1540
  // Semantic search is opt-in and local. Report availability + coverage without
1471
1541
  // loading the model (selectEmbedder only probes; embeddingStats just counts rows).
1472
1542
  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) {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Worktree wiring — make a repo's private-overlay memory seamless across every git
3
+ * worktree (v0.32+). A worktree's gitignored `.hunch/local.json` pointer doesn't exist
4
+ * in a fresh checkout, so we register the overlay at the SHARED git common dir
5
+ * (`git rev-parse --git-common-dir`), which every linked worktree resolves identically.
6
+ */
7
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { gitCommonDir } from "../extractors/git.js";
10
+ import { writeFileAtomic } from "../core/io.js";
11
+ /** Register the resolved private overlay at the shared git common dir, so every worktree
12
+ * of this repo auto-discovers the same memory. Idempotent (writes only when missing or
13
+ * changed). Stored ABSOLUTE — a worktree resolves relative paths from its OWN root.
14
+ * Returns true once the shared pointer is in place (memory is worktree-shared), false when
15
+ * there's no overlay configured or no git common dir. Reused by `init`/`worktree`/`private`. */
16
+ export function ensureSharedOverlayPointer(root, overlayDir, autoCommit) {
17
+ const common = overlayDir ? gitCommonDir(root) : "";
18
+ if (!common || !overlayDir)
19
+ return false;
20
+ const file = join(common, "hunch", "local.json");
21
+ const want = JSON.stringify({ privateDir: resolve(overlayDir), autoCommit }, null, 2) + "\n";
22
+ try {
23
+ if (!(existsSync(file) && readFileSync(file, "utf8") === want)) {
24
+ mkdirSync(join(common, "hunch"), { recursive: true });
25
+ writeFileAtomic(file, want);
26
+ }
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ //# sourceMappingURL=worktree.js.map
@@ -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.31.0",
3
+ "version": "0.33.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.",