@davesheffer/hunch 0.38.2 โ†’ 0.38.3

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
@@ -1,5 +1,6 @@
1
1
  # ๐Ÿง  Hunch โ€” Architectural Conformance for AI code
2
2
 
3
+ [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
3
4
  [![npm version](https://img.shields.io/npm/v/@davesheffer/hunch?color=2742ff&label=npm)](https://www.npmjs.com/package/@davesheffer/hunch)
4
5
  [![npm downloads](https://img.shields.io/npm/dw/@davesheffer/hunch?color=2742ff)](https://www.npmjs.com/package/@davesheffer/hunch)
5
6
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
package/dist/cli/index.js CHANGED
@@ -79,7 +79,8 @@ program
79
79
  .option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
80
80
  .option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
81
81
  .option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
82
- .option("--private-sync", "post-commit synthesis writes captured decisions into the private overlay (HUNCH_PRIVATE_DIR), never the public repo")
82
+ .option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
83
+ .option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
83
84
  .option("--auto-commit", "opt-in: the post-commit hook also git add+commit+pushes the captured decision (the repo it landed in)")
84
85
  .action((opts) => {
85
86
  // Validate --firmness up front, before any side effects (indexing, git hooks,
@@ -109,8 +110,9 @@ program
109
110
  console.log(` โš  ${res.skipped} file(s) could not be parsed (skipped)`);
110
111
  }
111
112
  if (isGitRepo(root)) {
112
- const h = installPostCommitHook(root, inv.shell, { private: opts.privateSync, commit: opts.autoCommit });
113
- console.log(` โœ“ post-commit hook ${h.action} (learning loop)${opts.privateSync ? " โ€” syncs to the private overlay" : ""}${opts.autoCommit ? " โ€” auto-commit+push on" : ""}`);
113
+ const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
114
+ const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
115
+ console.log(` โœ“ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " โ€” syncs to the shared overlay" : ""}${opts.autoCommit ? " โ€” auto-commit+push on" : ""}`);
114
116
  const m = installMergeDriver(root, inv.shell);
115
117
  console.log(` โœ“ team merge driver ${m.action}`);
116
118
  // Auto-install the pre-commit guard by default (advisory: flags invariants
@@ -266,7 +268,8 @@ program
266
268
  .option("--from-hook", "invoked by the git hook")
267
269
  .option("--quiet", "minimal output")
268
270
  .option("--force", "re-synthesize even if a decision already exists for the commit")
269
- .option("--private", "write the synthesized decision into the private overlay (HUNCH_PRIVATE_DIR), not the public repo โ€” for a repo whose memory is kept private")
271
+ .option("--private", "write the synthesized decision into the configured overlay (HUNCH_PRIVATE_DIR), not the public store")
272
+ .option("--overlay", "alias of --private")
270
273
  .option("--commit", "after a capture, also git add+commit+push the repo the decision landed in (opt-in; best-effort) โ€” the private store under --private, else this repo")
271
274
  .option("--deep", "Deep Synthesis: ensemble every available subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
272
275
  .option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
@@ -275,12 +278,13 @@ program
275
278
  const { store, root } = storeFor();
276
279
  if (!isGitRepo(root))
277
280
  return opts.quiet ? undefined : fail("sync needs a git repo");
278
- if (opts.private && !store.hasPrivate) {
281
+ const toOverlay = !!(opts.private || opts.overlay);
282
+ if (toOverlay && !store.hasPrivate) {
279
283
  store.close();
280
- return opts.quiet ? undefined : fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
284
+ return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
281
285
  }
282
286
  store.json.ensureDirs();
283
- const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: opts.private, deep: opts.deep, verify: opts.verify, samples: parseSamples(opts.samples) });
287
+ const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: toOverlay, deep: opts.deep, verify: opts.verify, samples: parseSamples(opts.samples) });
284
288
  if (r.status === "written") {
285
289
  store.reindex();
286
290
  // Don't rewrite grounding from the hook โ€” it would dirty the working tree on
@@ -296,7 +300,7 @@ program
296
300
  // just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
297
301
  // changes), and set HUNCH_SYNC=1 so the commit we create can't re-trigger this
298
302
  // hook (no recursion, including on a manual `hunch sync --commit`).
299
- const commitTarget = opts.commit ? (opts.private ? store.privateDir : hunchPaths(root).hunch) : undefined;
303
+ const commitTarget = opts.commit ? (toOverlay ? store.privateDir : hunchPaths(root).hunch) : undefined;
300
304
  if (commitTarget) {
301
305
  commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`);
302
306
  if (!opts.quiet)
@@ -310,29 +314,21 @@ program
310
314
  }
311
315
  store.close();
312
316
  });
313
- // ---- private (one-command setup for the private memory overlay) ------------
314
- program
315
- .command("private [dir]")
316
- .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).")
317
- .option("--repo <url>", "clone a private git repo to use as the store (into ./.hunch-private)")
318
- .option("--no-hook", "don't switch the post-commit hook to private sync")
319
- .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)")
320
- .option("--sync", "flush the configured private store now (git add+commit+push) โ€” catches records made via MCP between commits")
321
- .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")
322
- .action((dir, opts) => {
317
+ function configureOverlay(dir, opts, mode) {
323
318
  const root = findRoot();
319
+ const paths = hunchPaths(root);
320
+ const commandName = mode === "private" ? "private" : "shared";
324
321
  if (opts.sync) {
325
322
  const s = new HunchStore(hunchPaths(root));
326
323
  const target = s.privateDir;
327
324
  s.close();
328
325
  if (!target)
329
- return fail("no private overlay configured โ€” run `hunch private` first");
330
- commitAndPushHunch(target, "hunch: sync private memory");
331
- console.log(`โœ“ flushed private store โ†’ ${target}`);
326
+ return fail(`no overlay configured โ€” run \`hunch ${commandName}\` first`);
327
+ commitAndPushHunch(target, "hunch: sync overlay memory");
328
+ console.log(`โœ“ flushed overlay store โ†’ ${target}`);
332
329
  return;
333
330
  }
334
- const paths = hunchPaths(root);
335
- // 1) resolve the private store's hunch dir (holds decisions/, bugs/, โ€ฆ)
331
+ // 1) resolve the overlay store's hunch dir (holds decisions/, bugs/, โ€ฆ)
336
332
  let hunchDir;
337
333
  if (opts.repo) {
338
334
  const dest = join(root, ".hunch-private");
@@ -353,6 +349,13 @@ program
353
349
  // multiple machines/worktrees merge by RECORD ID (no manual conflict resolution) when the
354
350
  // two-way auto-sync pulls before pushing. The overlay repo root is the parent of its .hunch.
355
351
  const overlayRoot = hunchPathsForDir(hunchDir).root;
352
+ // CRITICAL (bug_overlay_clobber): with --auto-commit, the overlay MUST be its own git repo.
353
+ // Otherwise the post-commit auto-commit (commitAndPushHunch) runs `git -C overlayDir โ€ฆ` which
354
+ // walks UP to the PROJECT repo and can commit memory over your code. Initialize a standalone
355
+ // repo when one isn't there (a local repo with no remote just accumulates commits โ€” safe).
356
+ if (opts.autoCommit && !isGitRepo(overlayRoot)) {
357
+ spawnSync("git", ["init", "-q", overlayRoot], { stdio: "ignore" });
358
+ }
356
359
  if (isGitRepo(overlayRoot))
357
360
  installMergeDriver(overlayRoot, inv.shell);
358
361
  // 3) record the path in a GITIGNORED local config โ€” auto-detected, no env var, and
@@ -371,7 +374,7 @@ program
371
374
  // only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
372
375
  let worktreeNote = "";
373
376
  if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit)) {
374
- worktreeNote = ` โœ“ registered in the git common dir โ€” shared by every worktree of this repo, on any branch\n`;
377
+ worktreeNote = " โœ“ registered in the git common dir โ€” shared by every worktree of this repo, on any branch\n";
375
378
  }
376
379
  // 4) route post-commit synthesis to the overlay (local hook, never committed)
377
380
  let hookNote = "";
@@ -405,18 +408,40 @@ program
405
408
  ` next: review, then commit the PUBLIC repo:\n` +
406
409
  ` git add -A && git commit -m "chore: move engineering memory to a private overlay" && git push\n`;
407
410
  }
408
- console.log(`โœ“ private overlay enabled โ†’ ${hunchDir}\n` +
409
- ` โœ“ recorded in .hunch/local.json (gitignored) โ€” auto-detected, no env var or shell-profile edit\n` +
411
+ const lead = mode === "private"
412
+ ? `โœ“ private overlay enabled โ†’ ${hunchDir}\n`
413
+ : `โœ“ shared overlay enabled โ†’ ${hunchDir}\n`;
414
+ const tail = mode === "private"
415
+ ? " record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only."
416
+ : " this works for any repo (public or private): one shared memory source across teammates, branches, and worktrees.\n override per-shell with HUNCH_PRIVATE_DIR if needed.";
417
+ console.log(lead +
418
+ " โœ“ recorded in .hunch/local.json (gitignored) โ€” auto-detected, no env var or shell-profile edit\n" +
410
419
  worktreeNote +
411
420
  hookNote +
412
421
  migrateNote +
413
- ` record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n` +
414
- ` override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only.`);
415
- });
422
+ tail);
423
+ }
424
+ program
425
+ .command("private [dir]")
426
+ .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).")
427
+ .option("--repo <url>", "clone a private git repo to use as the store (into ./.hunch-private)")
428
+ .option("--no-hook", "don't switch the post-commit hook to private sync")
429
+ .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)")
430
+ .option("--sync", "flush the configured private store now (git add+commit+push) โ€” catches records made via MCP between commits")
431
+ .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")
432
+ .action((dir, opts) => configureOverlay(dir, opts, "private"));
433
+ program
434
+ .command("shared [dir]")
435
+ .description("Enable a SHARED memory overlay repo for this project (works for any repo: private or public). Memory stays in one location, shared across teammates, branches, and worktrees.")
436
+ .option("--repo <url>", "clone a git repo to use as the shared memory store (into ./.hunch-private)")
437
+ .option("--no-hook", "don't switch the post-commit hook to overlay sync")
438
+ .option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON โ€” fully automated two-way sync)")
439
+ .option("--sync", "flush the configured overlay store now (git add+commit+push)")
440
+ .action((dir, opts) => configureOverlay(dir, opts, "shared"));
416
441
  // ---- worktree (one-command worktree wired into Hunch) ----------------------
417
442
  program
418
443
  .command("worktree <path>")
419
- .description("Create a git worktree already wired into Hunch โ€” it shares this repo's memory (the private overlay), with zero per-worktree setup.")
444
+ .description("Create a git worktree already wired into Hunch โ€” it shares this repo's memory overlay, with zero per-worktree setup.")
420
445
  .option("-b, --branch <name>", "create the worktree on a NEW branch")
421
446
  .option("--no-share", "don't register the overlay at the git common dir (the worktree won't see private memory)")
422
447
  .option("--no-index", "don't build the new worktree's code graph (skip if you'll index later)")
@@ -448,7 +473,7 @@ program
448
473
  shareNote = ` ยท could not register the shared overlay pointer (no git common dir?)`;
449
474
  }
450
475
  else {
451
- shareNote = ` ยท no private overlay configured โ€” run \`hunch private\` to share memory across worktrees`;
476
+ shareNote = ` ยท no overlay configured โ€” run \`hunch shared\` (or \`hunch private\`) to share memory across worktrees`;
452
477
  }
453
478
  // 3) build the new worktree's CODE GRAPH (symbols/edges โ†’ blast-radius / dependents).
454
479
  // Indexed IN-PROCESS (uses THIS install's tree-sitter, so the worktree needs no
@@ -1753,7 +1778,7 @@ program
1753
1778
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
1754
1779
  console.log(store.privateDir
1755
1780
  ? `private: on โ†’ ${store.privateDir} (local overlay โ€” unioned into queries; never committed or posted publicly)`
1756
- : dim(`private: off โ€” run \`hunch private\` to keep sensitive memory in a separate repo (or set HUNCH_PRIVATE_DIR)`));
1781
+ : dim(`private: off โ€” run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
1757
1782
  // Worktree posture: linked worktrees share ONE memory via the git common dir. Only
1758
1783
  // surfaced in a linked worktree (no noise in a normal single checkout), so a
1759
1784
  // "memory missing here" symptom has an obvious cause + fix.
@@ -1762,7 +1787,7 @@ program
1762
1787
  const sharedPtr = !!common && existsSync(join(common, "hunch", "local.json"));
1763
1788
  console.log(store.privateDir
1764
1789
  ? `worktree: linked โ€” sharing the repo's memory${sharedPtr ? " via the git common dir" : ""}`
1765
- : dim(`worktree: linked, but no overlay resolved here โ€” run \`hunch private\` once (any worktree) so all worktrees share it`));
1790
+ : dim(`worktree: linked, but no overlay resolved here โ€” run \`hunch shared\` (or \`hunch private\`) once (any worktree) so all worktrees share it`));
1766
1791
  }
1767
1792
  // Semantic search is opt-in and local. Report availability + coverage without
1768
1793
  // loading the model (selectEmbedder only probes; embeddingStats just counts rows).
@@ -43,13 +43,30 @@ export function commitAndPushHunch(hunchDir, message) {
43
43
  catch { /* best-effort: nothing staged / not a repo / offline */ }
44
44
  };
45
45
  run(["add", "--", "."]);
46
- run(["commit", "-m", message]);
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))
46
+ // SAFETY BACKSTOP (critical โ€” bug_overlay_clobber): a memory sync is PURELY ADDITIVE small
47
+ // JSON. If the staged set contains a DELETION, rename, or any non-.json file, hunchDir is NOT
48
+ // a clean overlay store โ€” most dangerously, the overlay was never its own git repo so `git -C`
49
+ // walked UP to the PROJECT repo. Committing/pushing there would overwrite/delete the user's
50
+ // code (we shipped exactly this). Refuse hard: unstage and bail without committing or pushing.
51
+ if (!stagedIsMemoryOnly(hunchDir, env)) {
52
+ try {
53
+ execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
54
+ }
55
+ catch { /* best-effort unstage */ }
56
+ console.error(`hunch: refusing to auto-commit memory at "${hunchDir}" โ€” the staged change includes deletions or non-memory files, so this is not a clean overlay repo. Nothing was committed or pushed. (Use \`hunch shared --repo <url>\` so the overlay is its OWN git repo.)`);
57
+ return;
58
+ }
59
+ // Only sync+push when a memory commit was actually created โ€” never run pull/push against the
60
+ // enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
61
+ // can't be rejected non-fast-forward; the .hunch merge driver resolves same-record conflicts
62
+ // by id. On conflict/offline, mergeRemote aborts to a clean tree and we skip the push.
63
+ let committed = false;
64
+ try {
65
+ execFileSync("git", ["-C", hunchDir, "commit", "-m", message], { stdio: "ignore", env });
66
+ committed = true;
67
+ }
68
+ catch { /* nothing staged / not a repo */ }
69
+ if (committed && mergeRemote(hunchDir, env))
53
70
  run(["push"]);
54
71
  }
55
72
  finally {
@@ -59,6 +76,35 @@ export function commitAndPushHunch(hunchDir, message) {
59
76
  catch { /* released best-effort */ }
60
77
  }
61
78
  }
79
+ /** Is the staged set a clean, MEMORY-ONLY change โ€” only JSON record adds/updates, nothing else?
80
+ * The overlay store is entirely JSON (decisions/, bugs/, โ€ฆ, manifest.json). A real memory sync
81
+ * is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
82
+ * clean overlay repo (e.g. it resolved to the project repo), so committing there would clobber
83
+ * code. Empty stage โ‡’ false (nothing to commit). The transient mkdir lock is ignored. */
84
+ function stagedIsMemoryOnly(hunchDir, env) {
85
+ let out = "";
86
+ try {
87
+ out = execFileSync("git", ["-C", hunchDir, "diff", "--cached", "--name-status"], { encoding: "utf8", env });
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
93
+ if (!lines.length)
94
+ return false;
95
+ for (const line of lines) {
96
+ const parts = line.split("\t");
97
+ const status = (parts[0] ?? "").trim();
98
+ const path = (parts[parts.length - 1] ?? "").trim();
99
+ if (path.includes(".hunch-commit.lock"))
100
+ continue; // transient lock dir, never a record
101
+ if (!/^[AM]$/.test(status))
102
+ return false; // only Add / Modify โ€” any D/R/C/T โ†’ not a memory sync
103
+ if (!path.endsWith(".json"))
104
+ return false; // the store is entirely JSON records
105
+ }
106
+ return true;
107
+ }
62
108
  /** Merge the overlay's remote into the local branch (pull, no rebase), leaving a CLEAN tree
63
109
  * whether it succeeds or not. Returns true when the branch is safe to push (merged, or there's
64
110
  * no upstream to merge), false when a conflict was aborted (caller skips the push and retries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.38.2",
3
+ "version": "0.38.3",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture โ€” the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express โ€” grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",