@davesheffer/hunch 0.17.1 → 0.17.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
@@ -160,14 +160,15 @@ per-machine fixups. → [docs](https://hunch-pi.vercel.app/docs#team)
160
160
 
161
161
  ## Private memory (public repo, private context)
162
162
 
163
- Open-source your code without open-sourcing your *reasoning*. Point `HUNCH_PRIVATE_DIR` at a
164
- separate **private repo** and Hunch unions that store into every query and guard **locally**
165
- MCP and the pre-edit hook see your sensitive decisions/bugs/constraints while your public
166
- `.hunch/` stays clean. It's **opt-in and default-off** (unset the var fully inert), and
167
- **leak-safe by construction**: committed files and the CI PR comment render *public-only*, so a
168
- private record can't reach a public surface. Record sensitive items with `private: true`
169
- (`hunch_record_decision` / `hunch_record_correction`); `hunch doctor` shows whether the overlay
170
- is on. [docs](https://hunch-pi.vercel.app/docs#private)
163
+ Open-source your code without open-sourcing your *reasoning*. **`hunch private`** sets up a
164
+ separate private store in one command — Hunch unions it into every query and guard **locally**
165
+ (MCP and the pre-edit hook see your sensitive decisions/bugs/constraints) while your public
166
+ `.hunch/` stays clean. It writes a gitignored `.hunch/local.json` so it's auto-detected **no
167
+ env var, no shell-profile edit** (and `HUNCH_PRIVATE_DIR` still overrides per-shell). **Opt-in,
168
+ default-off** (no config fully inert), and **leak-safe by construction**: committed files and
169
+ the CI PR comment render *public-only*, so a private record can't reach a public surface. Record
170
+ sensitive items with `private: true` (`hunch_record_decision` / `hunch_record_correction`);
171
+ post-commit synthesis can route there too. → [docs](https://hunch-pi.vercel.app/docs#private)
171
172
 
172
173
  ## Continuous learning (CI)
173
174
 
package/dist/cli/index.js CHANGED
@@ -13,14 +13,16 @@
13
13
  * mcp start the MCP server (Claude Code connects here)
14
14
  * doctor environment diagnostics
15
15
  */
16
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
17
17
  import { execFileSync, spawnSync } from "node:child_process";
18
- import { relative, resolve } from "node:path";
18
+ import { join, relative, resolve, isAbsolute } from "node:path";
19
19
  import { Command } from "commander";
20
- import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
20
+ import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget } from "../core/paths.js";
21
+ import { writeFileAtomic } from "../core/io.js";
21
22
  import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
22
23
  import { HUNCH_VERSION } from "../core/version.js";
23
24
  import { HunchStore } from "../store/hunchStore.js";
25
+ import { JsonStore } from "../store/jsonStore.js";
24
26
  import { selectEmbedder } from "../store/embedder.js";
25
27
  import { indexRepo } from "../extractors/indexer.js";
26
28
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
@@ -65,6 +67,7 @@ program
65
67
  .option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
66
68
  .option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
67
69
  .option("--private-sync", "post-commit synthesis writes captured decisions into the private overlay (HUNCH_PRIVATE_DIR), never the public repo")
70
+ .option("--auto-commit", "opt-in: the post-commit hook also git add+commit+pushes the captured decision (the repo it landed in)")
68
71
  .action((opts) => {
69
72
  // Validate --firmness up front, before any side effects (indexing, git hooks,
70
73
  // .mcp.json) or opening the store — a bad value must not leave a half-init.
@@ -93,8 +96,8 @@ program
93
96
  console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
94
97
  }
95
98
  if (isGitRepo(root)) {
96
- const h = installPostCommitHook(root, inv.shell, { private: opts.privateSync });
97
- console.log(` ✓ post-commit hook ${h.action} (learning loop)${opts.privateSync ? " — syncs to the private overlay" : ""}`);
99
+ const h = installPostCommitHook(root, inv.shell, { private: opts.privateSync, commit: opts.autoCommit });
100
+ console.log(` ✓ post-commit hook ${h.action} (learning loop)${opts.privateSync ? " — syncs to the private overlay" : ""}${opts.autoCommit ? " — auto-commit+push on" : ""}`);
98
101
  const m = installMergeDriver(root, inv.shell);
99
102
  console.log(` ✓ team merge driver ${m.action}`);
100
103
  // Auto-install the pre-commit guard by default (advisory: flags invariants
@@ -225,6 +228,7 @@ program
225
228
  .option("--quiet", "minimal output")
226
229
  .option("--force", "re-synthesize even if a decision already exists for the commit")
227
230
  .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")
231
+ .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")
228
232
  .action(async (sha, opts) => {
229
233
  const { store, root } = storeFor();
230
234
  if (!isGitRepo(root))
@@ -241,6 +245,20 @@ program
241
245
  // on every commit. `hunch index`/`init` refresh it intentionally instead.
242
246
  if (!opts.fromHook)
243
247
  updateClaudeMd(root, store);
248
+ // Opt-in: persist the captured decision in the repo it landed in (private store
249
+ // under --private, else this repo). Best-effort — a non-repo dir / offline push
250
+ // just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
251
+ // changes), and set HUNCH_SYNC=1 so the commit we create can't re-trigger this
252
+ // hook (no recursion, including on a manual `hunch sync --commit`).
253
+ const commitTarget = opts.commit ? (opts.private ? store.privateDir : hunchPaths(root).hunch) : undefined;
254
+ if (commitTarget) {
255
+ const g = (args) => { spawnSync("git", ["-C", commitTarget, ...args], { stdio: "ignore", env: { ...process.env, HUNCH_SYNC: "1" } }); };
256
+ g(["add", "--", "."]);
257
+ g(["commit", "-m", `hunch: capture ${r.decision?.id ?? "decision"}`]);
258
+ g(["push"]);
259
+ if (!opts.quiet)
260
+ console.log(` ↳ committed + pushed ${r.decision?.id} (${commitTarget})`);
261
+ }
244
262
  if (!opts.quiet)
245
263
  console.log(`✓ captured decision ${r.decision?.id} via ${r.provider}: "${r.decision?.title}"`);
246
264
  }
@@ -249,6 +267,55 @@ program
249
267
  }
250
268
  store.close();
251
269
  });
270
+ // ---- private (one-command setup for the private memory overlay) ------------
271
+ program
272
+ .command("private [dir]")
273
+ .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).")
274
+ .option("--repo <url>", "clone a private git repo to use as the store (into ./.hunch-private)")
275
+ .option("--no-hook", "don't switch the post-commit hook to private sync")
276
+ .option("--auto-commit", "opt-in: the post-commit hook also git add+commit+pushes the private repo after each capture")
277
+ .action((dir, opts) => {
278
+ const root = findRoot();
279
+ const paths = hunchPaths(root);
280
+ // 1) resolve the private store's hunch dir (holds decisions/, bugs/, …)
281
+ let hunchDir;
282
+ if (opts.repo) {
283
+ const dest = join(root, ".hunch-private");
284
+ if (!existsSync(dest)) {
285
+ const r = spawnSync("git", ["clone", opts.repo, dest], { stdio: "inherit" });
286
+ if (r.status !== 0)
287
+ return fail(`git clone failed for ${opts.repo}`);
288
+ }
289
+ hunchDir = join(dest, ".hunch");
290
+ }
291
+ else {
292
+ hunchDir = dir ? resolve(root, dir) : join(root, ".hunch-private", ".hunch");
293
+ }
294
+ // 2) create the layout (decisions/, manifest, …) so it's queryable immediately
295
+ new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
296
+ // 3) record the path in a GITIGNORED local config — auto-detected, no env var, and
297
+ // the MCP server picks it up too. Atomic write (con_902759b3dc) since it's under .hunch/.
298
+ mkdirSync(paths.hunch, { recursive: true }); // tolerate a repo where `hunch init` hasn't run yet
299
+ // Store a repo-relative POSIX path when the store lives INSIDE the repo (portable +
300
+ // OS-clean — survives a repo move, resolves the same on any OS); an absolute path for
301
+ // a store elsewhere on disk. Resolution (env || local.json) re-resolves against root.
302
+ const rel = relative(root, hunchDir);
303
+ const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
304
+ writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored }, null, 2) + "\n");
305
+ ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
306
+ // 4) route post-commit synthesis to the overlay (local hook, never committed)
307
+ let hookNote = "";
308
+ if (opts.hook && isGitRepo(root)) {
309
+ const inv = resolveInvocation();
310
+ const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit });
311
+ hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
312
+ }
313
+ console.log(`✓ private overlay enabled → ${hunchDir}\n` +
314
+ ` ✓ recorded in .hunch/local.json (gitignored) — auto-detected, no env var or shell-profile edit\n` +
315
+ hookNote +
316
+ ` record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n` +
317
+ ` override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only.`);
318
+ });
252
319
  // ---- query ----------------------------------------------------------------
253
320
  program
254
321
  .command("query")
@@ -1049,10 +1116,9 @@ program
1049
1116
  }
1050
1117
  const c = store.reindex().counts;
1051
1118
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
1052
- const privDir = process.env.HUNCH_PRIVATE_DIR?.trim();
1053
- console.log(privDir
1054
- ? `private: on ${resolve(privDir)} (local overlay unioned into queries; never committed or posted publicly)`
1055
- : dim(`private: off — set HUNCH_PRIVATE_DIR to overlay a separate private memory repo for sensitive records`));
1119
+ console.log(store.privateDir
1120
+ ? `private: on → ${store.privateDir} (local overlay — unioned into queries; never committed or posted publicly)`
1121
+ : dim(`private: off run \`hunch private\` to keep sensitive memory in a separate repo (or set HUNCH_PRIVATE_DIR)`));
1056
1122
  // Semantic search is opt-in and local. Report availability + coverage without
1057
1123
  // loading the model (selectEmbedder only probes; embeddingStats just counts rows).
1058
1124
  const emb = await selectEmbedder();
@@ -19,6 +19,9 @@ const ENTRIES = [
19
19
  ".hunch/*.sqlite-wal",
20
20
  ".hunch/*.sqlite-journal",
21
21
  ".hunch/**/*.tmp*",
22
+ // Per-machine private-overlay pointer written by `hunch private` (holds the local
23
+ // path to the private store) — never committed.
24
+ ".hunch/local.json",
22
25
  // A local PRIVATE overlay store (HUNCH_PRIVATE_DIR) for sensitive memory — never
23
26
  // committed. This is the conventional in-repo path; point the env elsewhere for a
24
27
  // fully separate private repo.
@@ -11,14 +11,16 @@ const MARK = "# >>> hunch post-commit >>>";
11
11
  const ENDMARK = "# <<< hunch post-commit <<<";
12
12
  function block(invocation, opts = {}) {
13
13
  // --private routes the auto-synthesized decision into the HUNCH_PRIVATE_DIR overlay
14
- // instead of the public repo. The hook script is local (.git/hooks/), never committed,
15
- // so a repo whose memory is kept private leaves no trace of this in the public tree.
14
+ // instead of the public repo. --commit (opt-in) also commits & pushes the repo the
15
+ // decision landed in (the private store under --private, else this repo). The hook
16
+ // script is local (.git/hooks/), never committed.
16
17
  const priv = opts.private ? " --private" : "";
18
+ const commit = opts.commit ? " --commit" : "";
17
19
  return [
18
20
  MARK,
19
21
  'if [ -z "$HUNCH_SYNC" ]; then',
20
22
  " export HUNCH_SYNC=1",
21
- ` ( ${invocation} sync --from-hook --quiet${priv} >/dev/null 2>&1 || true ) &`,
23
+ ` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
22
24
  "fi",
23
25
  ENDMARK,
24
26
  ].join("\n");
@@ -10,7 +10,8 @@
10
10
  * - bugLineage(): bugs matching a symptom/symbol + their lineage
11
11
  * - fragility(): ranked fragility report with evidence
12
12
  */
13
- import { resolve } from "node:path";
13
+ import { resolve, join } from "node:path";
14
+ import { existsSync, readFileSync } from "node:fs";
14
15
  import { toPosixTarget, hunchPathsForDir } from "../core/paths.js";
15
16
  import { ENTITY_KINDS } from "../core/types.js";
16
17
  import { openDb } from "./db.js";
@@ -27,6 +28,9 @@ export class HunchStore {
27
28
  /** Optional PRIVATE overlay (HUNCH_PRIVATE_DIR) — a second store in a repo the
28
29
  * user controls. Unioned into reads via recs(); never written by public paths. */
29
30
  privateJson;
31
+ /** The resolved private-overlay hunch dir (from env or .hunch/local.json), or undefined
32
+ * when no overlay is configured. Surfaced so `hunch doctor` reflects the true state. */
33
+ privateDir;
30
34
  /** When true, recs() ignores the private overlay (public-only). Set transiently by
31
35
  * buildCheckReport({publicOnly}) so any PUBLICLY-POSTED report (the CI PR comment)
32
36
  * can never render a private record — a publicly-posted output is a leak surface
@@ -36,9 +40,28 @@ export class HunchStore {
36
40
  constructor(paths) {
37
41
  this.paths = paths;
38
42
  this.json = new JsonStore(paths);
39
- const priv = process.env.HUNCH_PRIVATE_DIR?.trim();
40
- if (priv)
41
- this.privateJson = new JsonStore(hunchPathsForDir(resolve(priv)));
43
+ // Private overlay location: env (override, for CI / portability) → else a gitignored
44
+ // local config (.hunch/local.json) so `hunch private` enables it with NO env var, and
45
+ // the MCP server / hook pick it up automatically. Relative paths resolve from root.
46
+ const priv = process.env.HUNCH_PRIVATE_DIR?.trim() || this.localPrivateDir();
47
+ if (priv) {
48
+ this.privateDir = resolve(this.paths.root, priv);
49
+ this.privateJson = new JsonStore(hunchPathsForDir(this.privateDir));
50
+ }
51
+ }
52
+ /** The private-overlay path from the gitignored `.hunch/local.json` (per-machine,
53
+ * never committed). Tolerant: undefined on missing/invalid so reads never crash. */
54
+ localPrivateDir() {
55
+ try {
56
+ const f = join(this.paths.hunch, "local.json");
57
+ if (!existsSync(f))
58
+ return undefined;
59
+ const v = JSON.parse(readFileSync(f, "utf8"));
60
+ return typeof v.privateDir === "string" && v.privateDir.trim() ? v.privateDir.trim() : undefined;
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
42
65
  }
43
66
  /** Merged read: public ∪ private overlay (private wins on id collision). Every
44
67
  * QUERY / REINDEX path uses this so MCP + the guards see private memory. Public-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
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.",