@davesheffer/hunch 0.33.0 → 0.34.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/dist/cli/index.js CHANGED
@@ -35,6 +35,7 @@ import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkrepo
35
35
  import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
36
36
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
37
37
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
38
+ import { flushPrivate } from "../integrations/sync.js";
38
39
  import { installMergeDriver } from "../integrations/mergeDriver.js";
39
40
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
40
41
  import { writeCiWorkflow } from "../integrations/ciAction.js";
@@ -312,7 +313,7 @@ program
312
313
  .description("Enable a PRIVATE memory overlay — sensitive decisions/bugs/constraints kept in a separate location, unioned into local queries, never committed here. Writes a gitignored .hunch/local.json so it's auto-detected (no env var needed).")
313
314
  .option("--repo <url>", "clone a private git repo to use as the store (into ./.hunch-private)")
314
315
  .option("--no-hook", "don't switch the post-commit hook to private sync")
315
- .option("--auto-commit", "opt-in: also git add+commit+push the private repo after each capture (post-commit hook AND MCP private writes)")
316
+ .option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON — fully automated two-way sync, never push by hand)")
316
317
  .option("--sync", "flush the configured private store now (git add+commit+push) — catches records made via MCP between commits")
317
318
  .option("--migrate", "ONE-TIME: move this repo's EXISTING public .hunch memory into the overlay, then make the public repo code-only — untrack + gitignore the memory tree and regenerate grounding so no memory is published here")
318
319
  .action((dir, opts) => {
@@ -344,6 +345,13 @@ program
344
345
  }
345
346
  // 2) create the layout (decisions/, manifest, …) so it's queryable immediately
346
347
  new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
348
+ const inv = resolveInvocation();
349
+ // Install the structured merge driver IN the overlay repo so concurrent captures from
350
+ // multiple machines/worktrees merge by RECORD ID (no manual conflict resolution) when the
351
+ // two-way auto-sync pulls before pushing. The overlay repo root is the parent of its .hunch.
352
+ const overlayRoot = hunchPathsForDir(hunchDir).root;
353
+ if (isGitRepo(overlayRoot))
354
+ installMergeDriver(overlayRoot, inv.shell);
347
355
  // 3) record the path in a GITIGNORED local config — auto-detected, no env var, and
348
356
  // the MCP server picks it up too. Atomic write (con_902759b3dc) since it's under .hunch/.
349
357
  mkdirSync(paths.hunch, { recursive: true }); // tolerate a repo where `hunch init` hasn't run yet
@@ -365,7 +373,6 @@ program
365
373
  // 4) route post-commit synthesis to the overlay (local hook, never committed)
366
374
  let hookNote = "";
367
375
  if (opts.hook && isGitRepo(root)) {
368
- const inv = resolveInvocation();
369
376
  const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit });
370
377
  hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
371
378
  }
@@ -409,6 +416,7 @@ program
409
416
  .description("Create a git worktree already wired into Hunch — it shares this repo's memory (the private overlay), with zero per-worktree setup.")
410
417
  .option("-b, --branch <name>", "create the worktree on a NEW branch")
411
418
  .option("--no-share", "don't register the overlay at the git common dir (the worktree won't see private memory)")
419
+ .option("--no-index", "don't build the new worktree's code graph (skip if you'll index later)")
412
420
  .action((path, opts) => {
413
421
  const root = findRoot();
414
422
  if (!isGitRepo(root))
@@ -439,8 +447,28 @@ program
439
447
  else {
440
448
  shareNote = ` · no private overlay configured — run \`hunch private\` to share memory across worktrees`;
441
449
  }
450
+ // 3) build the new worktree's CODE GRAPH (symbols/edges → blast-radius / dependents).
451
+ // Indexed IN-PROCESS (uses THIS install's tree-sitter, so the worktree needs no
452
+ // node_modules) and ONLY when the graph isn't already committed in the checkout —
453
+ // re-parsing a normal repo would just dirty its tracked .hunch/*.json. Writes the
454
+ // derived (gitignored) index, never the working tree.
455
+ let indexNote = "";
456
+ if (opts.index !== false) {
457
+ const wstore = new HunchStore(hunchPaths(dest));
458
+ wstore.json.ensureDirs();
459
+ if (wstore.json.loadAll("symbols").length === 0) {
460
+ const res = indexRepo(wstore, dest);
461
+ wstore.reindex();
462
+ indexNote = `\n ✓ indexed ${res.files} file(s) → ${res.symbols} symbols — blast-radius ready (code graph isn't committed here)`;
463
+ }
464
+ else {
465
+ wstore.reindex(); // committed graph already in the checkout → just build the derived SQLite
466
+ indexNote = `\n ✓ code graph present in the checkout — blast-radius ready`;
467
+ }
468
+ wstore.close();
469
+ }
442
470
  console.log(`✓ worktree created → ${dest}${opts.branch ? ` (new branch ${opts.branch})` : ""}\n` +
443
- `${shareNote}\n` +
471
+ `${shareNote}${indexNote}\n` +
444
472
  ` hooks + MCP server are shared (worktree-aware) — open your assistant in the new worktree to start.\n` +
445
473
  ` (needs \`hunch\` installed globally; a worktree has no node_modules of its own)`);
446
474
  });
@@ -653,6 +681,8 @@ program
653
681
  }
654
682
  }
655
683
  store.reindex();
684
+ if (opts.private && (dec || con))
685
+ flushPrivate(store, `hunch: capture ${dec + con} inline intent(s)`);
656
686
  if (!intents.length)
657
687
  console.log("No `hunch-why:` / `hunch-rule:` comments found.");
658
688
  else
@@ -44,7 +44,51 @@ export function commitAndPushHunch(hunchDir, message) {
44
44
  };
45
45
  run(["add", "--", "."]);
46
46
  run(["commit", "-m", message]);
47
- run(["push"]);
47
+ // Two-way sync: MERGE the remote BEFORE pushing, so a push can never be rejected
48
+ // non-fast-forward (the cause of memory piling up unpushed across machines). The .hunch
49
+ // merge driver resolves same-record conflicts by id; non-overlapping records merge cleanly
50
+ // without it. On an unresolved conflict / offline, mergeRemote aborts to a clean tree and we
51
+ // skip the push — the local commit stays and syncs on the next write (never lost).
52
+ if (mergeRemote(hunchDir, env))
53
+ run(["push"]);
54
+ }
55
+ finally {
56
+ try {
57
+ rmSync(lock, { recursive: true, force: true });
58
+ }
59
+ catch { /* released best-effort */ }
60
+ }
61
+ }
62
+ /** Merge the overlay's remote into the local branch (pull, no rebase), leaving a CLEAN tree
63
+ * whether it succeeds or not. Returns true when the branch is safe to push (merged, or there's
64
+ * no upstream to merge), false when a conflict was aborted (caller skips the push and retries
65
+ * on the next write). Never throws. */
66
+ function mergeRemote(hunchDir, env) {
67
+ const tryGit = (args) => {
68
+ try {
69
+ execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
70
+ return true;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ };
76
+ if (!tryGit(["rev-parse", "--abbrev-ref", "@{upstream}"]))
77
+ return true; // no remote/offline → push no-ops
78
+ if (tryGit(["pull", "--no-edit", "--no-rebase", "--autostash"]))
79
+ return true;
80
+ tryGit(["merge", "--abort"]); // conflict the driver couldn't resolve → restore a clean tree
81
+ return false;
82
+ }
83
+ /** Best-effort READ-side sync: merge the overlay's remote into the local branch (e.g. on MCP
84
+ * server start) so this machine/session sees other machines' memory. Never throws; leaves a
85
+ * clean tree. Serialized with the commit lock so it can't race a concurrent flush. */
86
+ export function pullHunch(hunchDir) {
87
+ const lock = join(hunchDir, ".hunch-commit.lock");
88
+ if (!acquireCommitLock(lock))
89
+ return;
90
+ try {
91
+ mergeRemote(hunchDir, { ...process.env, HUNCH_SYNC: "1" });
48
92
  }
49
93
  finally {
50
94
  try {
@@ -0,0 +1,8 @@
1
+ import { commitAndPushHunch } from "../extractors/git.js";
2
+ /** Auto-commit + push the overlay after a private write, when auto-commit is on. No-op
3
+ * otherwise (manual `hunch private --sync` still works). Never throws. */
4
+ export function flushPrivate(store, message) {
5
+ if (store.privateAutoCommit && store.privateDir)
6
+ commitAndPushHunch(store.privateDir, message);
7
+ }
8
+ //# sourceMappingURL=sync.js.map
@@ -14,7 +14,7 @@ import { HunchStore } from "../store/hunchStore.js";
14
14
  import { selectEmbedder } from "../store/embedder.js";
15
15
  import { decisionId } from "../core/ids.js";
16
16
  import { buildCorrectionConstraint } from "../core/correction.js";
17
- import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, commitAndPushHunch } from "../extractors/git.js";
17
+ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, commitAndPushHunch, pullHunch } from "../extractors/git.js";
18
18
  import { formatContext } from "../core/format.js";
19
19
  import { compareCandidates } from "../core/compare.js";
20
20
  import { checkConformance } from "../core/conformance.js";
@@ -52,6 +52,15 @@ function resolveFiles(store, target) {
52
52
  }
53
53
  export function buildServer(root) {
54
54
  const store = new HunchStore(hunchPaths(root));
55
+ // Two-way sync (read side): pull the private overlay's remote on startup, so THIS machine's
56
+ // session sees memory captured on other machines/worktrees before we index — making the
57
+ // overlay genuinely one source of truth. Best-effort, leaves a clean tree, never blocks start.
58
+ if (store.privateDir) {
59
+ try {
60
+ pullHunch(store.privateDir);
61
+ }
62
+ catch { /* offline / no remote — proceed with local */ }
63
+ }
55
64
  // Ensure the SQLite index reflects the JSON source of truth on startup.
56
65
  try {
57
66
  store.reindex();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.33.0",
3
+ "version": "0.34.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.",