@davesheffer/hunch 0.39.0 → 1.0.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
@@ -4,7 +4,7 @@
4
4
  [![npm version](https://img.shields.io/npm/v/@davesheffer/hunch?color=2742ff&label=npm)](https://www.npmjs.com/package/@davesheffer/hunch)
5
5
  [![npm downloads](https://img.shields.io/npm/dw/@davesheffer/hunch?color=2742ff)](https://www.npmjs.com/package/@davesheffer/hunch)
6
6
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
7
- [![node](https://img.shields.io/badge/node-%E2%89%A520-2742ff)](https://nodejs.org)
7
+ [![node](https://img.shields.io/badge/node-%E2%89%A522.13-2742ff)](https://nodejs.org)
8
8
  [![MCP](https://img.shields.io/badge/MCP-native-2742ff)](https://modelcontextprotocol.io)
9
9
 
10
10
  > **A linter checks whether code matches a *pattern*. Hunch checks whether code still matches your *architecture*** —
@@ -98,10 +98,49 @@ combination is the moat:
98
98
  The short version: **git tracks *what* changed; Hunch tracks *why*** — locally, durably, and under
99
99
  your control, with guards that actually hold the line instead of just suggesting.
100
100
 
101
+ ## Decision-grounding: memory that stays true to the doc
102
+
103
+ Architectural Conformance keeps the *code* honest to the graph (**graph ≠ code**). Decision-grounding
104
+ is its complement — it keeps your *docs* honest to the graph (**doc ≠ graph**). A comment or a README
105
+ says one thing; the decision that actually governs the file says another. Both are "memory that stays
106
+ true"; you want both.
107
+
108
+ The anchor is one optional field. A decision can carry a **`topic`** — the thing it's the current answer
109
+ for (e.g. `"auth.session"`) — and topic gives you a query contract: **current** (the one live answer),
110
+ **history** (the supersede trail), and **rejected** (what was ruled out and why). It's fully
111
+ backward-compatible: `topic` defaults to `null`, there's **no schema bump**, and existing graphs load
112
+ unchanged.
113
+
114
+ - **Read-time grounding.** The pre-edit (PreToolUse) hook now surfaces a file's topic-anchored decisions
115
+ *before* the AI writes — with doc-precedence framing ("follow the graph, not a stale doc") and what each
116
+ decision **rejected**, so the model doesn't happily re-add the approach you already ruled out.
117
+ - **`anchor-stale` drift — deterministic, no guessing.** A new drift kind fires when a file is still
118
+ anchored to a **superseded** decision while a **current** one exists for its topic. It shows up in
119
+ `hunch doctor` and in a CI-gateable `hunch drift`:
120
+
121
+ ```bash
122
+ hunch drift # ⛔ exits non-zero on anchor-stale drift or a topic collision (>1 live decision)
123
+ ```
124
+
125
+ It only fires on **explicit** topic anchors — no semantic guessing, no false positives on prose it can't
126
+ read.
127
+ - **Capture, gated.** `hunch_record_decision` now enforces a store-scoped **uniqueness guard**: it refuses
128
+ a *second* live decision for a topic (you're never silently governed by two). The richer path is the new
129
+ **`hunch_capture_decision`** tool — it returns a one-question-at-a-time grilling protocol plus a
130
+ capture-session token; `record_decision` accepts an optional `capture_token`. Un-token'd writes still
131
+ work, they just get a nudge toward `/capture`. **`hunch_current_decision(topic)`** returns the one answer
132
+ that currently governs a topic.
133
+ - **`hunch reconcile-topics`.** A git merge is the one thing that can create two live decisions for a
134
+ topic. This scans for it and exits non-zero — wire it into a post-merge hook or CI.
135
+ - **`hunch heal`** + the **`/capture`** and **`/heal`** slash commands (scaffolded by `hunch init`) do
136
+ **read-only** doc↔graph reconciliation — they surface the mismatch and never rewrite your prose silently.
137
+
138
+ → [docs](https://hunch-pi.vercel.app/docs#grounding)
139
+
101
140
  ## Getting started
102
141
 
103
142
  ```bash
104
- npm install -g @davesheffer/hunch # Node ≥ 20; puts `hunch` on your PATH
143
+ npm install -g @davesheffer/hunch # Node ≥ 22.13; puts `hunch` on your PATH
105
144
  cd your-repo
106
145
  hunch init # scaffold .hunch/, index, install hooks, wire up assistants
107
146
  hunch backfill --since 90d # cold start: seed decisions from recent git history
@@ -182,6 +221,10 @@ defined elsewhere and `hunch check` / the CI guard / `hunch_merge_verdict` flag
182
221
  existing location. **Advisory** — it never blocks, and it's tuned to stay quiet so a refactor
183
222
  that just moves code isn't mistaken for a duplicate. → [docs](https://hunch-pi.vercel.app/docs#redundancy)
184
223
 
224
+ See also **[Decision-grounding](#decision-grounding-memory-that-stays-true-to-the-doc)** — the doc ≠ graph
225
+ complement: topic anchors, read-time grounding in the pre-edit hook, and a deterministic `anchor-stale`
226
+ drift check (`hunch drift`) that fails CI when a file still points at a superseded decision.
227
+
185
228
  Plus the **Regression Guard** (re-adding deliberately-retired code) and the
186
229
  **[CI Constraint Guard](https://hunch-pi.vercel.app/docs#ci)** (`hunch ci` — a PR gate that
187
230
  comments the affected `con_`/`dec_` ids and fails on a blocking one).
@@ -207,9 +250,16 @@ Windows / macOS / Linux teammates share one memory with no per-machine fixups.
207
250
  Memory follows you across every branch and **git worktree**, with no per-worktree setup — a
208
251
  fresh `git worktree add` on any branch sees the same decisions, bugs, and invariants. Create one
209
252
  already wired in with **`hunch worktree <path> [-b <branch>]`**, or just run `hunch init` / `hunch
210
- private` once and every worktree picks it up. Parallel worktrees never corrupt or lose memory, and
253
+ shared` (or `hunch private`) once and every worktree picks it up. Parallel worktrees never corrupt or lose memory, and
211
254
  `hunch doctor` confirms a worktree is sharing.
212
255
 
256
+ Need one **single source of truth** for memory in any repo (private or public)?
257
+ Use **`hunch shared --repo <url>`**. Every capture — decisions, bugs, constraints, runbooks —
258
+ routes to one shared overlay repo and, by default, auto-commits + pushes so teammates/other
259
+ worktrees stay in sync automatically. It also publishes a committed **`.hunch/team.json`**
260
+ pointing at the store, so a fresh clone auto-connects on `hunch init` (agents and CI wire up
261
+ the same way via the MCP server) — everyone, on every branch, resolves the same memory.
262
+
213
263
  ## Private memory (public repo, private context)
214
264
 
215
265
  Open-source your code without open-sourcing your *reasoning*. **`hunch private`** sets up a
@@ -220,8 +270,10 @@ env var, no shell-profile edit** (and `HUNCH_PRIVATE_DIR` still overrides per-sh
220
270
  default-off** (no config → fully inert), and **leak-safe by construction**: committed files and
221
271
  the CI PR comment render *public-only*, so a private record can't reach a public surface. Record
222
272
  sensitive items with `private: true` (`hunch_record_decision` / `hunch_record_correction`);
223
- post-commit synthesis can route there too, and `hunch private --auto-commit` (opt-in)
224
- auto-commits + pushes each capture to the private repo recursion-safe, staging only `.hunch/`.
273
+ post-commit synthesis can route there too. Every capture is **auto-committed by default** to the
274
+ store it lands in the private repo is committed + pushed; a public capture is committed to
275
+ `.hunch/` only and rides your next push (Hunch never pushes or merges your code branch) —
276
+ recursion-safe, staging only `.hunch/`. Opt out with `--no-auto-commit`.
225
277
 
226
278
  Already published a repo *with* its `.hunch/` memory and want it private after the fact?
227
279
  `hunch private --repo <url> --migrate` does it in one shot: it **moves** your existing public
@@ -268,5 +320,5 @@ memory. → [the docs](https://hunch-pi.vercel.app/docs) for the conceptual mode
268
320
 
269
321
  ## Develop
270
322
 
271
- Hunch is open source — pure TypeScript ESM, Node ≥ 20, licensed **Apache-2.0**. Contributions
323
+ Hunch is open source — pure TypeScript ESM, Node ≥ 22.13, licensed **Apache-2.0**. Contributions
272
324
  welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the [repo](https://github.com/davesheffer/hunch).
package/dist/cli/index.js CHANGED
@@ -28,7 +28,8 @@ 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, gitCommonDir, isLinkedWorktree } from "../extractors/git.js";
31
+ import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
32
+ import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
32
33
  import { runbookId, decisionId } from "../core/ids.js";
33
34
  import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
34
35
  import { extractInlineIntent } from "../extractors/comments.js";
@@ -36,7 +37,7 @@ import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkrepo
36
37
  import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
37
38
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
38
39
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
39
- import { flushPrivate } from "../integrations/sync.js";
40
+ import { flushCapture } from "../integrations/sync.js";
40
41
  import { installMergeDriver } from "../integrations/mergeDriver.js";
41
42
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
42
43
  import { writeCiWorkflow } from "../integrations/ciAction.js";
@@ -82,7 +83,7 @@ program
82
83
  .option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
83
84
  .option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
84
85
  .option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
85
- .option("--auto-commit", "opt-in: the post-commit hook also git add+commit+pushes the captured decision (the repo it landed in)")
86
+ .option("--no-auto-commit", "DON'T auto-commit captures (default: ON in every mode — the overlay repo is committed+pushed; the public .hunch/ is committed only and rides your next push)")
86
87
  .action((opts) => {
87
88
  // Validate --firmness up front, before any side effects (indexing, git hooks,
88
89
  // .mcp.json) or opening the store — a bad value must not leave a half-init.
@@ -91,12 +92,18 @@ program
91
92
  }
92
93
  const root = findRoot();
93
94
  const paths = hunchPaths(root);
95
+ // Team auto-discovery FIRST: a committed .hunch/team.json advertises the shared
96
+ // store — a fresh clone wires itself to it before anything reads memory, so every
97
+ // teammate/agent resolves the same single source of truth with zero manual setup.
98
+ const teamWired = ensureTeamOverlay(root);
94
99
  const store = new HunchStore(paths);
95
100
  openStore = store; // so the top-level error handler closes it on failure
96
101
  const inv = resolveInvocation();
97
102
  console.log(`🧠 Initializing Hunch at ${root}`);
98
103
  store.json.ensureDirs(); // stamps the manifest at the current version when fresh
99
104
  console.log(` ✓ .hunch/ scaffolded (schema v${readManifest(paths).schema_version})`);
105
+ if (teamWired)
106
+ console.log(` ✓ connected to the team's shared memory store (from .hunch/team.json) → ${teamWired}`);
100
107
  // Exclude the derived SQLite index BEFORE it's written, so the working tree
101
108
  // never goes dirty on the MCP server's index writes (which blocks branch
102
109
  // switches). The .hunch/*.json graph stays tracked.
@@ -110,10 +117,22 @@ program
110
117
  if (res.skipped)
111
118
  console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
112
119
  }
120
+ // Auto-commit is ON by default in every mode; --no-auto-commit persists the opt-out in
121
+ // the gitignored local.json (merge — never clobber an existing overlay pointer).
122
+ if (opts.autoCommit === false) {
123
+ const localFile = join(paths.hunch, "local.json");
124
+ let existing = {};
125
+ try {
126
+ existing = JSON.parse(readFileSync(localFile, "utf8"));
127
+ }
128
+ catch { /* absent/invalid → fresh */ }
129
+ writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
130
+ console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
131
+ }
113
132
  if (isGitRepo(root)) {
114
133
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
115
134
  const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
116
- console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit+push on" : ""}`);
135
+ console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
117
136
  const m = installMergeDriver(root, inv.shell);
118
137
  console.log(` ✓ team merge driver ${m.action}`);
119
138
  // Auto-install the pre-commit guard by default (advisory: flags invariants
@@ -164,7 +183,7 @@ program
164
183
  // Worktree-seamless: register any configured overlay at the git common dir so EVERY
165
184
  // worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
166
185
  // and note when we're initializing inside a linked worktree (memory is shared, not separate).
167
- if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit)) {
186
+ if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private")) {
168
187
  console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
169
188
  }
170
189
  if (isLinkedWorktree(root)) {
@@ -271,7 +290,8 @@ program
271
290
  .option("--force", "re-synthesize even if a decision already exists for the commit")
272
291
  .option("--private", "write the synthesized decision into the configured overlay (HUNCH_PRIVATE_DIR), not the public store")
273
292
  .option("--overlay", "alias of --private")
274
- .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")
293
+ .option("--commit", "after a capture, also git add+commit the repo the decision landed in (default: follows auto-commit, ON unless opted out) — the overlay is also pushed; the public .hunch/ rides your next push")
294
+ .option("--no-commit", "skip the auto-commit for this capture even when auto-commit is on")
275
295
  .option("--deep", "Deep Synthesis: ensemble every available subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
276
296
  .option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
277
297
  .option("--samples <n>", "self-consistency depth when only one CLI is installed: sample it n times and reconcile (default 2 under --deep)")
@@ -279,7 +299,9 @@ program
279
299
  const { store, root } = storeFor();
280
300
  if (!isGitRepo(root))
281
301
  return opts.quiet ? undefined : fail("sync needs a git repo");
282
- const toOverlay = !!(opts.private || opts.overlay);
302
+ // In unified ("shared") mode every capture routes to the overlay — the sync path
303
+ // must agree with captureHome so all writers home records identically.
304
+ const toOverlay = !!(opts.private || opts.overlay || store.unified);
283
305
  if (toOverlay && !store.hasPrivate) {
284
306
  store.close();
285
307
  return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
@@ -296,16 +318,20 @@ program
296
318
  if (healed.length && !opts.quiet)
297
319
  console.log(` ↳ grounding refreshed: ${healed.join(", ")}`);
298
320
  }
299
- // Opt-in: persist the captured decision in the repo it landed in (private store
300
- // under --private, else this repo). Best-effort a non-repo dir / offline push
321
+ // Persist the captured decision in the repo it landed in (private store under
322
+ // --private, else this repo). ON by default (follows auto-commit; --no-commit or
323
+ // `--no-auto-commit` at setup opts out). Best-effort — a non-repo dir / offline push
301
324
  // just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
302
325
  // changes), and set HUNCH_SYNC=1 so the commit we create can't re-trigger this
303
- // hook (no recursion, including on a manual `hunch sync --commit`).
304
- const commitTarget = opts.commit ? (toOverlay ? store.privateDir : hunchPaths(root).hunch) : undefined;
326
+ // hook (no recursion). The overlay is pushed; the public .hunch/ is committed
327
+ // WITHOUT pushing auto-pushing the user's code branch would publish their
328
+ // unpushed commits (bug_overlay_clobber lineage).
329
+ const doCommit = opts.commit ?? store.autoCommit;
330
+ const commitTarget = doCommit ? (toOverlay ? store.privateDir : hunchPaths(root).hunch) : undefined;
305
331
  if (commitTarget) {
306
- commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`);
332
+ commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`, { push: toOverlay });
307
333
  if (!opts.quiet)
308
- console.log(` ↳ committed + pushed ${r.decision?.id} (${commitTarget})`);
334
+ console.log(` ↳ committed ${toOverlay ? "+ pushed " : ""}${r.decision?.id} (${commitTarget}${toOverlay ? "" : " — rides your next push"})`);
309
335
  }
310
336
  if (!opts.quiet)
311
337
  console.log(`✓ captured decision ${r.decision?.id} via ${r.provider}: "${r.decision?.title}"`);
@@ -331,17 +357,44 @@ function configureOverlay(dir, opts, mode) {
331
357
  }
332
358
  // 1) resolve the overlay store's hunch dir (holds decisions/, bugs/, …)
333
359
  let hunchDir;
360
+ // Anchor the default store at the MAIN worktree root: a linked worktree can be
361
+ // `git worktree remove`d, which would take the store (and every other worktree's
362
+ // absolute pointer to it) down with it. An explicit [dir] still resolves from here.
363
+ const anchor = mainWorktreeRoot(root);
334
364
  if (opts.repo) {
335
- const dest = join(root, ".hunch-private");
365
+ const dest = join(anchor, ".hunch-private");
336
366
  if (!existsSync(dest)) {
337
367
  const r = spawnSync("git", ["clone", opts.repo, dest], { stdio: "inherit" });
338
368
  if (r.status !== 0)
339
369
  return fail(`git clone failed for ${opts.repo}`);
340
370
  }
371
+ else {
372
+ // NEVER silently ignore --repo when the store dir already exists: same remote →
373
+ // freshen; no remote → attach + converge; different remote → refuse loudly.
374
+ const cur = spawnSync("git", ["-C", dest, "remote", "get-url", "origin"], { encoding: "utf8" });
375
+ const existingUrl = cur.status === 0 ? cur.stdout.trim() : "";
376
+ if (existingUrl === opts.repo) {
377
+ pullHunch(join(dest, ".hunch"));
378
+ console.log(` · ${dest} already tracks ${opts.repo} — pulled the latest memory`);
379
+ }
380
+ else if (!existingUrl) {
381
+ if (!isGitRepo(dest))
382
+ spawnSync("git", ["init", "-q", dest], { stdio: "ignore" });
383
+ spawnSync("git", ["-C", dest, "remote", "add", "origin", opts.repo], { stdio: "ignore" });
384
+ spawnSync("git", ["-C", dest, "fetch", "-q", "origin"], { stdio: "ignore" });
385
+ spawnSync("git", ["-C", dest, "merge", "-q", "--no-edit", "--allow-unrelated-histories", "FETCH_HEAD"], { stdio: "ignore" });
386
+ spawnSync("git", ["-C", dest, "push", "-q", "-u", "origin", "HEAD"], { stdio: "ignore" });
387
+ console.log(` · attached the existing local store ${dest} to ${opts.repo} (merged + pushed, best-effort)`);
388
+ }
389
+ else {
390
+ return fail(`${dest} already tracks a DIFFERENT remote:\n current: ${existingUrl}\n requested: ${opts.repo}\n` +
391
+ `Refusing to silently re-point your memory. Move that directory aside, or pass an explicit dir: \`hunch ${commandName} <dir> --repo <url>\`.`);
392
+ }
393
+ }
341
394
  hunchDir = join(dest, ".hunch");
342
395
  }
343
396
  else {
344
- hunchDir = dir ? resolve(root, dir) : join(root, ".hunch-private", ".hunch");
397
+ hunchDir = dir ? resolve(root, dir) : join(anchor, ".hunch-private", ".hunch");
345
398
  }
346
399
  // 2) create the layout (decisions/, manifest, …) so it's queryable immediately
347
400
  new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
@@ -367,14 +420,23 @@ function configureOverlay(dir, opts, mode) {
367
420
  // a store elsewhere on disk. Resolution (env || local.json) re-resolves against root.
368
421
  const rel = relative(root, hunchDir);
369
422
  const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
370
- writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored, autoCommit: !!opts.autoCommit }, null, 2) + "\n");
423
+ writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored, autoCommit: !!opts.autoCommit, mode }, null, 2) + "\n");
371
424
  ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
425
+ // SHARED mode with a remote: publish the store's URL in a COMMITTED team.json, so a
426
+ // fresh clone / new teammate / headless agent auto-connects on `hunch init` (or MCP
427
+ // server start) — everyone resolves the same single source of truth. Private mode
428
+ // never publishes its URL.
429
+ let teamNote = "";
430
+ if (mode === "shared" && opts.repo) {
431
+ writeTeamConfig(root, { shared_repo: opts.repo });
432
+ teamNote = " ✓ published .hunch/team.json (commit it) — teammates, worktrees, and agents auto-connect\n";
433
+ }
372
434
  // Also register the overlay at the SHARED git common dir, so EVERY worktree of this repo
373
435
  // (current + future, any branch) auto-discovers the same memory with zero per-worktree
374
436
  // setup. Stored ABSOLUTE — a linked worktree resolves relative paths from its OWN root, so
375
437
  // only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
376
438
  let worktreeNote = "";
377
- if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit)) {
439
+ if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode)) {
378
440
  worktreeNote = " ✓ registered in the git common dir — shared by every worktree of this repo, on any branch\n";
379
441
  }
380
442
  // 4) route post-commit synthesis to the overlay (local hook, never committed)
@@ -414,10 +476,11 @@ function configureOverlay(dir, opts, mode) {
414
476
  : `✓ shared overlay enabled → ${hunchDir}\n`;
415
477
  const tail = mode === "private"
416
478
  ? " 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."
417
- : " 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.";
479
+ : " UNIFIED: every capture (decisions, bugs, constraints, runbooks) routes HERE one source of truth\n across branches, worktrees, teammates, and agents. Override per-shell with HUNCH_PRIVATE_DIR if needed.";
418
480
  console.log(lead +
419
481
  " ✓ recorded in .hunch/local.json (gitignored) — auto-detected, no env var or shell-profile edit\n" +
420
482
  worktreeNote +
483
+ teamNote +
421
484
  hookNote +
422
485
  migrateNote +
423
486
  tail);
@@ -693,10 +756,7 @@ program
693
756
  provenance: { source: "extracted", confidence: 0.5, evidence: [range] },
694
757
  date: now,
695
758
  };
696
- if (opts.private)
697
- store.putPrivate("runbooks", rec);
698
- else
699
- store.json.put("runbooks", rec);
759
+ store.putCapture("runbooks", rec, opts.private);
700
760
  store.reindex();
701
761
  console.log(`✓ runbook ${rec.id} — "${rec.task}" (${rec.steps.length} steps, ${rec.files.length} files)${opts.private ? " [private overlay]" : ""}`);
702
762
  console.log(dim(" advisory, deterministic draft — refine the steps/gotchas; surfaced via `hunch query` and MCP."));
@@ -730,10 +790,7 @@ program
730
790
  retired: { symbols: [], deps: [] },
731
791
  provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev }, date: prev?.date ?? now,
732
792
  };
733
- if (opts.private)
734
- store.putPrivate("decisions", rec);
735
- else
736
- store.json.put("decisions", rec);
793
+ store.putCapture("decisions", rec, opts.private);
737
794
  dec++;
738
795
  }
739
796
  else {
@@ -749,16 +806,13 @@ program
749
806
  valid_from: prev?.valid_from ?? now, valid_to: null,
750
807
  provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev },
751
808
  };
752
- if (opts.private)
753
- store.putPrivate("constraints", rec);
754
- else
755
- store.json.put("constraints", rec);
809
+ store.putCapture("constraints", rec, opts.private);
756
810
  con++;
757
811
  }
758
812
  }
759
813
  store.reindex();
760
- if (opts.private && (dec || con))
761
- flushPrivate(store, `hunch: capture ${dec + con} inline intent(s)`);
814
+ if (dec || con)
815
+ flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`);
762
816
  if (!intents.length)
763
817
  console.log("No `hunch-why:` / `hunch-rule:` comments found.");
764
818
  else
@@ -790,7 +844,7 @@ program
790
844
  store.json.ensureDirs();
791
845
  const now = new Date().toISOString();
792
846
  const arrow = opts.assert.startsWith("not-") ? "↛" : "→";
793
- const d = store.json.put("decisions", {
847
+ const d = store.putCapture("decisions", {
794
848
  id: decisionId(`conform:${opts.add}:${opts.subject}:${opts.object ?? ""}`),
795
849
  title: opts.add,
796
850
  status: "accepted",
@@ -1023,7 +1077,7 @@ program
1023
1077
  forbids = deriveForbids(statement, deps.length ? deps : undefined);
1024
1078
  derived = !!forbids;
1025
1079
  }
1026
- const c = store.json.put("constraints", {
1080
+ const c = store.putCapture("constraints", {
1027
1081
  id: constraintId(statement),
1028
1082
  type: opts.type,
1029
1083
  statement,
@@ -1308,7 +1362,7 @@ vetoCmd
1308
1362
  if ((d.rejected_tripwires?.length ?? 0) > 0)
1309
1363
  continue; // never clobber existing tripwires
1310
1364
  const tws = draftTripwires(d.alternatives_rejected, d.related_files, knownDeps);
1311
- store.json.put("decisions", { ...d, rejected_tripwires: tws });
1365
+ store.putWhereItLives("decisions", { ...d, rejected_tripwires: tws });
1312
1366
  drafted += tws.length;
1313
1367
  touched++;
1314
1368
  }
@@ -1569,7 +1623,7 @@ function acceptDecision(store, d) {
1569
1623
  last_verified: now,
1570
1624
  },
1571
1625
  }));
1572
- store.json.put("decisions", { ...d, status: "accepted", rejected_tripwires: confirmedTws, provenance: { ...d.provenance, source, confidence: 0.95, last_verified: now } });
1626
+ store.putWhereItLives("decisions", { ...d, status: "accepted", rejected_tripwires: confirmedTws, provenance: { ...d.provenance, source, confidence: 0.95, last_verified: now } });
1573
1627
  const armed = confirmedTws.filter((tw) => tw.forbids.deps.length || tw.forbids.symbols.length || tw.forbids.patterns.length).length;
1574
1628
  return { source, armed };
1575
1629
  }
@@ -1856,9 +1910,24 @@ program
1856
1910
  }
1857
1911
  const c = store.reindex().counts;
1858
1912
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
1859
- console.log(store.privateDir
1860
- ? `private: on ${store.privateDir} (local overlay unioned into queries; never committed or posted publicly)`
1861
- : dim(`private: off run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
1913
+ // Overlay status speaks the TRUE mode, and a dead pointer is a loud finding, not a
1914
+ // silent empty store: the JSON reader degrades to [] when the target dir is missing,
1915
+ // so this is the one place the loss is visible.
1916
+ if (store.privateDir && !existsSync(store.privateDir)) {
1917
+ console.log(`overlay: ⛔ POINTER IS DEAD → ${store.privateDir} does not exist — shared/private memory is NOT being read.`);
1918
+ console.log(` fix: re-run \`hunch ${store.mode === "shared" ? "shared" : "private"} --repo <url>\` (or restore the directory); the pointer lives in .hunch/local.json / the git common dir`);
1919
+ }
1920
+ else if (store.privateDir) {
1921
+ console.log(store.mode === "shared"
1922
+ ? `shared: on → ${store.privateDir} (UNIFIED — every capture routes here; one source of truth across branches, worktrees, teammates, agents)`
1923
+ : `private: on → ${store.privateDir} (local overlay — unioned into queries; never committed or posted publicly)`);
1924
+ }
1925
+ else {
1926
+ const team = readTeamConfig(root);
1927
+ console.log(team
1928
+ ? `overlay: off, but .hunch/team.json advertises the team store (${team.shared_repo}) — run \`hunch init\` to auto-connect`
1929
+ : dim(`private: off — run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
1930
+ }
1862
1931
  // Worktree posture: linked worktrees share ONE memory via the git common dir. Only
1863
1932
  // surfaced in a linked worktree (no noise in a normal single checkout), so a
1864
1933
  // "memory missing here" symptom has an obvious cause + fix.
@@ -1,7 +1,7 @@
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";
4
+ import { isAbsolute, resolve, join, basename, dirname } from "node:path";
5
5
  import { mkdirSync, rmSync, statSync, realpathSync } from "node:fs";
6
6
  function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
7
7
  // stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
@@ -21,12 +21,28 @@ function gitSafe(args, cwd, maxBuffer) {
21
21
  export function isGitRepo(cwd) {
22
22
  return gitSafe(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
23
23
  }
24
+ /** The MAIN worktree's root — the stable anchor for an overlay store. A linked worktree
25
+ * can be `git worktree remove`d, so anything anchored inside it (an overlay clone, an
26
+ * absolute pointer target) silently dies for every OTHER worktree; the main checkout
27
+ * can't be removed. Falls back to `root` when the layout isn't the standard `.git` dir
28
+ * (or not a git repo), preserving today's behavior. */
29
+ export function mainWorktreeRoot(root) {
30
+ const common = gitCommonDir(root);
31
+ if (!common)
32
+ return root;
33
+ const abs = resolve(root, common);
34
+ return basename(abs) === ".git" ? dirname(abs) : root;
35
+ }
24
36
  /** Best-effort: stage ONLY the hunch dir, commit, and push the repo it lives in. Shared by
25
37
  * the post-commit auto-commit (CLI sync --commit), MCP private writes, and `hunch private
26
38
  * --sync`. HUNCH_SYNC=1 stops the created commit from re-triggering the post-commit hook
27
39
  * (no recursion). Stages with a pathspec scoped to `hunchDir`, so it never sweeps unrelated
28
- * working-tree changes. Never throws — a non-repo dir / offline push just no-ops. */
29
- export function commitAndPushHunch(hunchDir, message) {
40
+ * working-tree changes. Never throws — a non-repo dir / offline push just no-ops.
41
+ * `push: false` commits WITHOUT merging or pushing — required when hunchDir is the PUBLIC
42
+ * .hunch/ inside the user's code repo: an automatic pull/push there would merge the remote
43
+ * into their working branch and publish their unpushed code commits. The memory commit
44
+ * simply rides the user's next push. */
45
+ export function commitAndPushHunch(hunchDir, message, opts = {}) {
30
46
  // Serialize across worktrees: several worktrees auto-committing the SAME overlay repo
31
47
  // at once would race git's index.lock. An atomic-mkdir lock lets one proceed; the others
32
48
  // skip — safe because each record is already written to disk, so `git add .` here sweeps
@@ -53,7 +69,13 @@ export function commitAndPushHunch(hunchDir, message) {
53
69
  execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
54
70
  }
55
71
  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.)`);
72
+ // Public-store commits (push:false) skip QUIETLY: a non-memory staged set there is
73
+ // usually just the user's own staged work, not a misconfigured overlay — the record
74
+ // stays on disk and the next flush's `git add .` sweeps it up. The overlay path
75
+ // stays loud: there it signals the escaped-to-project-repo misconfiguration.
76
+ if (opts.push !== false) {
77
+ 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.)`);
78
+ }
57
79
  return;
58
80
  }
59
81
  // Only sync+push when a memory commit was actually created — never run pull/push against the
@@ -66,7 +88,7 @@ export function commitAndPushHunch(hunchDir, message) {
66
88
  committed = true;
67
89
  }
68
90
  catch { /* nothing staged / not a repo */ }
69
- if (committed && mergeRemote(hunchDir, env))
91
+ if (committed && opts.push !== false && mergeRemote(hunchDir, env))
70
92
  run(["push"]);
71
93
  }
72
94
  finally {
@@ -5,4 +5,22 @@ export function flushPrivate(store, message) {
5
5
  if (store.privateAutoCommit && store.privateDir)
6
6
  commitAndPushHunch(store.privateDir, message);
7
7
  }
8
+ /** Auto-commit the store a capture landed in. Returns what happened so callers can report it:
9
+ * "pushed" (private overlay, committed + pushed), "committed" (public .hunch/, commit only —
10
+ * rides the next push), or null (auto-commit off / no overlay for a private record). */
11
+ export function flushCapture(store, publicHunchDir, isPrivate, message) {
12
+ // Follow the same routing as HunchStore.captureHome: unified ("shared") mode homes
13
+ // EVERY capture in the overlay, so the flush must go there too — one source of truth.
14
+ if (store.captureHome(isPrivate) === "private") {
15
+ if (store.privateAutoCommit && store.privateDir) {
16
+ commitAndPushHunch(store.privateDir, message);
17
+ return "pushed";
18
+ }
19
+ return null;
20
+ }
21
+ if (!store.autoCommit)
22
+ return null;
23
+ commitAndPushHunch(publicHunchDir, message, { push: false });
24
+ return "committed";
25
+ }
8
26
  //# sourceMappingURL=sync.js.map
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Team discovery for the shared memory store — the COMMITTED half of the resolution chain.
3
+ * `.hunch/local.json` (gitignored, per-machine) says where THIS machine's overlay lives;
4
+ * `.hunch/team.json` (committed, public) says where the TEAM's shared store lives, so a
5
+ * fresh clone / a new teammate / a headless agent can auto-wire without being told.
6
+ * Written ONLY by `hunch shared --repo <url>` — `hunch private` never publishes its URL.
7
+ */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ import { writeFileAtomic } from "../core/io.js";
12
+ import { hunchPaths, hunchPathsForDir } from "../core/paths.js";
13
+ import { mainWorktreeRoot } from "../extractors/git.js";
14
+ import { HunchStore } from "../store/hunchStore.js";
15
+ import { JsonStore } from "../store/jsonStore.js";
16
+ import { ensureSharedOverlayPointer } from "./worktree.js";
17
+ /** SECURITY GATE for team.json's URL. team.json is COMMITTED — in a freshly cloned
18
+ * (possibly untrusted) repo it is attacker-controlled, and ensureTeamOverlay auto-clones
19
+ * it on MCP server start. Without this gate a value like `--upload-pack=…` (argument
20
+ * smuggling) or `ext::sh -c …` (git's ext transport) is remote code execution from
21
+ * merely opening a repo. Allow only https:// / ssh:// / git:// / scp-style git@host:path,
22
+ * and never anything that could parse as a git flag. */
23
+ export function safeGitUrl(url) {
24
+ const u = url.trim();
25
+ if (!u || u.startsWith("-"))
26
+ return null; // flag smuggling
27
+ if (/^(https|ssh|git):\/\/[^\s]+$/i.test(u))
28
+ return u;
29
+ if (/^[A-Za-z0-9_.-]+@[A-Za-z0-9_.:-]+:[^\s]+$/.test(u) && !u.includes("::"))
30
+ return u; // scp-like, excludes ext::
31
+ // A plain absolute path (POSIX / Windows drive / UNC) — a network-mount team store or a
32
+ // local test remote. Safe: a local clone never executes hooks or remote helpers. The
33
+ // file:// URL FORM stays rejected (no legitimate team.json uses it; keeps the gate tight).
34
+ if (u.startsWith("/") || /^[A-Za-z]:[\\/]/.test(u) || u.startsWith("\\\\"))
35
+ return u;
36
+ return null;
37
+ }
38
+ /** The committed team pointer, or null. Tolerant — an invalid file reads as absent, and
39
+ * a URL that fails the safety gate reads as absent too (never propagated to a consumer). */
40
+ export function readTeamConfig(root) {
41
+ try {
42
+ const file = join(hunchPaths(root).hunch, "team.json");
43
+ if (!existsSync(file))
44
+ return null;
45
+ const v = JSON.parse(readFileSync(file, "utf8"));
46
+ const url = typeof v.shared_repo === "string" ? safeGitUrl(v.shared_repo) : null;
47
+ return url ? { shared_repo: url } : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** Publish the team's shared-store URL (atomic; committed with the repo). */
54
+ export function writeTeamConfig(root, cfg) {
55
+ writeFileAtomic(join(hunchPaths(root).hunch, "team.json"), JSON.stringify(cfg, null, 2) + "\n");
56
+ }
57
+ /** Auto-wire this checkout to the team's shared store advertised in `.hunch/team.json`:
58
+ * clone it to the worktree-stable anchor, and register the gitignored local pointer +
59
+ * the git-common-dir pointer (mode "shared", auto-commit on) so every consumer — CLI,
60
+ * MCP server, hooks, all worktrees — resolves the same single source of truth.
61
+ * No-op (null) when an overlay is already configured, there's no team.json, or the
62
+ * clone fails (best-effort: never throws, never blocks startup). Returns the overlay
63
+ * hunch dir when wired. */
64
+ export function ensureTeamOverlay(root) {
65
+ try {
66
+ if (process.env.HUNCH_PRIVATE_DIR?.trim())
67
+ return null; // explicit env wins
68
+ const team = readTeamConfig(root);
69
+ if (!team)
70
+ return null;
71
+ const probe = new HunchStore(hunchPaths(root));
72
+ const configured = probe.privateDir;
73
+ probe.close();
74
+ if (configured && existsSync(configured))
75
+ return null; // already wired and alive
76
+ const anchor = mainWorktreeRoot(root);
77
+ const dest = join(anchor, ".hunch-private");
78
+ if (!existsSync(dest)) {
79
+ // Defense in depth on top of safeGitUrl: `--` stops flag parsing, the protocol
80
+ // allowlist + ext:: kill-switch block command-running transports, and no terminal
81
+ // prompt means a private remote can't hang a headless MCP/agent start.
82
+ const r = spawnSync("git", ["-c", "protocol.ext.allow=never", "clone", "--", team.shared_repo, dest], {
83
+ stdio: "ignore",
84
+ env: { ...process.env, GIT_ALLOW_PROTOCOL: "https:ssh:git:file", GIT_TERMINAL_PROMPT: "0" },
85
+ });
86
+ if (r.status !== 0)
87
+ return null; // offline / no access — stay unwired, never crash
88
+ }
89
+ const hunchDir = join(dest, ".hunch");
90
+ new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
91
+ // Merge into any existing local.json (con_8460b6770f): a per-machine autoCommit
92
+ // opt-out must survive the auto-wiring; an unparseable file is left alone.
93
+ const localFile = join(hunchPaths(root).hunch, "local.json");
94
+ let existing = {};
95
+ if (existsSync(localFile)) {
96
+ try {
97
+ existing = JSON.parse(readFileSync(localFile, "utf8"));
98
+ }
99
+ catch {
100
+ return null;
101
+ } // refuse to clobber an unparseable config
102
+ }
103
+ const autoCommit = existing.autoCommit !== false;
104
+ writeFileAtomic(localFile, JSON.stringify({ ...existing, privateDir: hunchDir, autoCommit, mode: "shared" }, null, 2) + "\n");
105
+ ensureSharedOverlayPointer(root, hunchDir, autoCommit, "shared");
106
+ return hunchDir;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ //# sourceMappingURL=team.js.map
@@ -11,14 +11,16 @@ import { writeFileAtomic } from "../core/io.js";
11
11
  /** Register the resolved private overlay at the shared git common dir, so every worktree
12
12
  * of this repo auto-discovers the same memory. Idempotent (writes only when missing or
13
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) {
14
+ * Carries the overlay MODE so every worktree routes captures identically (shared =
15
+ * unified store, private = split). Returns true once the shared pointer is in place
16
+ * (memory is worktree-shared), false when there's no overlay configured or no git
17
+ * common dir. Reused by `init`/`worktree`/`private`/`shared`. */
18
+ export function ensureSharedOverlayPointer(root, overlayDir, autoCommit, mode = "private") {
17
19
  const common = overlayDir ? gitCommonDir(root) : "";
18
20
  if (!common || !overlayDir)
19
21
  return false;
20
22
  const file = join(common, "hunch", "local.json");
21
- const want = JSON.stringify({ privateDir: resolve(overlayDir), autoCommit }, null, 2) + "\n";
23
+ const want = JSON.stringify({ privateDir: resolve(overlayDir), autoCommit, mode }, null, 2) + "\n";
22
24
  try {
23
25
  if (!(existsSync(file) && readFileSync(file, "utf8") === want)) {
24
26
  mkdirSync(join(common, "hunch"), { recursive: true });
@@ -16,7 +16,9 @@ import { decisionId } from "../core/ids.js";
16
16
  import { buildCorrectionConstraint } from "../core/correction.js";
17
17
  import { knownRepoDeps } from "../synthesis/tripwires.js";
18
18
  import { refreshExistingGrounding } from "../integrations/providers.js";
19
- import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, commitAndPushHunch, pullHunch } from "../extractors/git.js";
19
+ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, pullHunch } from "../extractors/git.js";
20
+ import { flushCapture } from "../integrations/sync.js";
21
+ import { ensureTeamOverlay } from "../integrations/team.js";
20
22
  import { formatContext } from "../core/format.js";
21
23
  import { compareCandidates } from "../core/compare.js";
22
24
  import { checkConformance } from "../core/conformance.js";
@@ -75,6 +77,14 @@ function resolveFiles(store, target) {
75
77
  return files.size ? [...files] : [toPosixTarget(target)];
76
78
  }
77
79
  export function buildServer(root) {
80
+ // Team auto-discovery: a committed .hunch/team.json advertises the shared store — a
81
+ // fresh clone (a new teammate, a headless agent, a CI workflow) wires itself BEFORE the
82
+ // store is constructed, so every consumer resolves the same single source of truth.
83
+ // Best-effort: offline / no team.json → proceed exactly as before.
84
+ try {
85
+ ensureTeamOverlay(root);
86
+ }
87
+ catch { /* never block server start */ }
78
88
  const store = new HunchStore(hunchPaths(root));
79
89
  // Two-way sync (read side): pull the private overlay's remote on startup, so THIS machine's
80
90
  // session sees memory captured on other machines/worktrees before we index — making the
@@ -391,10 +401,11 @@ export function buildServer(root) {
391
401
  `re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
392
402
  }
393
403
  }
394
- // Route the write: private records go to the HUNCH_PRIVATE_DIR overlay (never
395
- // the committed repo); everything else to the public store. putPrivate throws
396
- // if no private store is configured, so "private" can't silently fall public.
397
- if (decision.private)
404
+ // Route the write to its ONE home (captureHome): an explicit private:true goes to
405
+ // the overlay (putPrivate throws rather than silently falling public); in unified
406
+ // ("shared") mode EVERY capture goes to the overlay; else the public store.
407
+ const home = store.captureHome(!!decision.private);
408
+ if (home === "private")
398
409
  store.putPrivate("decisions", rec);
399
410
  else
400
411
  store.json.put("decisions", rec);
@@ -404,16 +415,15 @@ export function buildServer(root) {
404
415
  // private overlay; a public one in the committed store. A private write never
405
416
  // mutates the public store.
406
417
  const superseded = decision.supersedes
407
- ? (decision.private ? store.supersedePrivate(decision.supersedes, rec) : store.supersede(decision.supersedes, rec))
418
+ ? (home === "private" ? store.supersedePrivate(decision.supersedes, rec) : store.supersede(decision.supersedes, rec))
408
419
  : null;
409
420
  store.reindex();
410
- // Auto-flush the private repo when configured (hunch private --auto-commit), so a
411
- // record made via MCP between public commits is committed+pushed immediately.
412
- let flushed = "";
413
- if (decision.private && store.privateAutoCommit && store.privateDir) {
414
- commitAndPushHunch(store.privateDir, `hunch: capture ${id}`);
415
- flushed = " (committed + pushed to the private repo)";
416
- }
421
+ // Auto-flush the store the record landed in (on by default in every mode): a private
422
+ // record commits+pushes its overlay repo; a public one commits .hunch/ in THIS repo
423
+ // (commit only — it rides the user's next push, never auto-pushing their code branch).
424
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`);
425
+ const flushed = flush === "pushed" ? ` (committed + pushed to the ${store.mode === "shared" ? "shared team store" : "private repo"})`
426
+ : flush === "committed" ? " (auto-committed to .hunch/ rides your next push)" : "";
417
427
  // Capture-session gate (staged deprecation, §9.3): a token proves an interview
418
428
  // preceded the write. No token still writes (non-breaking), but returns a nudge
419
429
  // toward /capture so the un-interviewed bypass is visible, not silent. A token
@@ -426,7 +436,9 @@ export function buildServer(root) {
426
436
  : "\n\n⚠ Recorded WITHOUT a capture interview. Prefer /capture (hunch_capture_decision), which grills the decision to a resolved state before writing — the graph should hold a well-examined decision, not a guess. (A future major version will require a capture token here.)";
427
437
  const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
428
438
  const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
429
- const where = decision.private ? ` [PRIVATE overlay — not committed to this repo]${flushed}` : "";
439
+ const where = decision.private
440
+ ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
441
+ : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
430
442
  return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}${captureNote}`);
431
443
  }
432
444
  catch (e) {
@@ -454,8 +466,9 @@ export function buildServer(root) {
454
466
  const rec = buildCorrectionConstraint({ ...input, knownDeps: knownRepoDeps(root) }, new Date().toISOString());
455
467
  // Private corrections go to the overlay (enforced locally via the merged read,
456
468
  // never rendered into the public CI comment, which is public-only by construction).
457
- const existing = input.private ? undefined : store.json.get("constraints", rec.id);
458
- if (input.private)
469
+ const home = store.captureHome(!!input.private);
470
+ const existing = home === "private" ? undefined : store.json.get("constraints", rec.id);
471
+ if (home === "private")
459
472
  store.putPrivate("constraints", rec);
460
473
  else
461
474
  store.json.put("constraints", rec);
@@ -464,17 +477,17 @@ export function buildServer(root) {
464
477
  // Windsurf/AGENTS.md/CLAUDE.md), so a correction captured in one assistant is held
465
478
  // by all of them. Public only — a private rule must never render into committed
466
479
  // grounding. Refresh-only: it never scaffolds a doc the project opted out of.
467
- if (!input.private)
468
- refreshExistingGrounding(root, store);
469
- let flushed = "";
470
- if (input.private && store.privateAutoCommit && store.privateDir) {
471
- commitAndPushHunch(store.privateDir, `hunch: capture ${rec.id}`);
472
- flushed = " (committed + pushed to the private repo)";
473
- }
480
+ if (home === "public")
481
+ refreshExistingGrounding(root, store); // overlay rules never render into committed grounding
482
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`);
483
+ const flushed = flush === "pushed" ? ` (committed + pushed to the ${store.mode === "shared" ? "shared team store" : "private repo"})`
484
+ : flush === "committed" ? " (auto-committed to .hunch/ rides your next push)" : "";
474
485
  const enforce = rec.severity === "blocking"
475
486
  ? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
476
487
  : "flags violating edits and PRs (advisory)";
477
- const where = input.private ? ` [PRIVATE overlay — not committed to this repo]${flushed}` : "";
488
+ const where = input.private
489
+ ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
490
+ : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
478
491
  return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.`);
479
492
  }
480
493
  catch (e) {
package/dist/store/db.js CHANGED
@@ -1,19 +1,57 @@
1
- /** Thin wrapper around better-sqlite3 for the derived index. */
2
- import Database from "better-sqlite3";
1
+ /** Thin wrapper around node:sqlite for the derived index. */
2
+ import { createRequire } from "node:module";
3
3
  import { mkdirSync } from "node:fs";
4
4
  import { dirname } from "node:path";
5
5
  import { SCHEMA_SQL } from "./schema.js";
6
+ /** Load node:sqlite while swallowing ONLY its ExperimentalWarning (Node 22–24 still
7
+ * emits it on module load). Hunch's stderr reaches humans, hooks, and MCP clients on
8
+ * every invocation, so the noise would land everywhere; all other warnings pass through. */
9
+ function loadSqlite() {
10
+ const require = createRequire(import.meta.url);
11
+ const realEmit = process.emitWarning.bind(process);
12
+ process.emitWarning = ((warning, ...rest) => {
13
+ if (String(warning).includes("SQLite is an experimental feature"))
14
+ return;
15
+ realEmit(warning, ...rest);
16
+ });
17
+ try {
18
+ return require("node:sqlite");
19
+ }
20
+ finally {
21
+ process.emitWarning = realEmit;
22
+ }
23
+ }
24
+ const sqlite = loadSqlite();
6
25
  export function openDb(sqlitePath) {
7
26
  mkdirSync(dirname(sqlitePath), { recursive: true });
8
- const db = new Database(sqlitePath);
9
- db.pragma("busy_timeout = 5000");
27
+ const db = new sqlite.DatabaseSync(sqlitePath);
28
+ db.exec("PRAGMA busy_timeout = 5000");
10
29
  db.exec(SCHEMA_SQL);
11
30
  return db;
12
31
  }
13
32
  /** In-memory db (tests / ephemeral queries). */
14
33
  export function openMemoryDb() {
15
- const db = new Database(":memory:");
34
+ const db = new sqlite.DatabaseSync(":memory:");
16
35
  db.exec(SCHEMA_SQL);
17
36
  return db;
18
37
  }
38
+ /** Run `fn` inside one transaction: BEGIN → fn → COMMIT, ROLLBACK on throw.
39
+ * (node:sqlite has no better-sqlite3-style transaction() helper.) */
40
+ export function withTx(db, fn) {
41
+ db.exec("BEGIN");
42
+ try {
43
+ const out = fn();
44
+ db.exec("COMMIT");
45
+ return out;
46
+ }
47
+ catch (err) {
48
+ try {
49
+ db.exec("ROLLBACK");
50
+ }
51
+ catch {
52
+ /* connection already rolled back */
53
+ }
54
+ throw err;
55
+ }
56
+ }
19
57
  //# sourceMappingURL=db.js.map
@@ -14,7 +14,7 @@ import { resolve, join } from "node:path";
14
14
  import { existsSync, readFileSync } from "node:fs";
15
15
  import { toPosixTarget, hunchPathsForDir } from "../core/paths.js";
16
16
  import { ENTITY_KINDS } from "../core/types.js";
17
- import { openDb } from "./db.js";
17
+ import { openDb, withTx } 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";
@@ -33,8 +33,21 @@ export class HunchStore {
33
33
  /** The resolved private-overlay hunch dir (from env or .hunch/local.json), or undefined
34
34
  * when no overlay is configured. Surfaced so `hunch doctor` reflects the true state. */
35
35
  privateDir;
36
- /** Whether private writes should auto commit+push the private repo (from local.json
37
- * `autoCommit`, set by `hunch private --auto-commit`). Read by the MCP write tools. */
36
+ /** Whether captures auto-commit the store they land in — ON by default in EVERY mode;
37
+ * `--no-auto-commit` (hunch init/private/shared) persists `autoCommit: false` in
38
+ * local.json to opt out. Read by the MCP write tools and `hunch sync`. */
39
+ autoCommit;
40
+ /** How memory is homed: "public" (no overlay — the repo-tracked .hunch/ is the one store),
41
+ * "private" (overlay holds ONLY private:true records; public records stay committed here),
42
+ * or "shared" (the overlay IS the store — every capture routes there, one source of truth
43
+ * across branches, worktrees, teammates, and agents). Absent `mode` in an existing
44
+ * config reads as "private" — no behavior change on upgrade. */
45
+ mode;
46
+ /** mode === "shared" with a configured overlay: ALL captures route to the overlay. */
47
+ unified;
48
+ /** autoCommit AND a private overlay is configured: private writes auto commit+push the
49
+ * overlay repo. (Public writes auto-commit the repo-tracked .hunch/ WITHOUT pushing —
50
+ * see commitAndPushHunch push:false / bug_overlay_clobber.) */
38
51
  privateAutoCommit;
39
52
  /** When true, recs() ignores the private overlay (public-only). Set transiently by
40
53
  * buildCheckReport({publicOnly}) so any PUBLICLY-POSTED report (the CI PR comment)
@@ -54,10 +67,51 @@ export class HunchStore {
54
67
  this.privateDir = resolve(this.paths.root, priv);
55
68
  this.privateJson = new JsonStore(hunchPathsForDir(this.privateDir));
56
69
  }
57
- this.privateAutoCommit = !!(priv && local.autoCommit);
70
+ // Auto-commit is ON unless explicitly opted out (`autoCommit: false` in local.json).
71
+ // An absent local.json (plain `hunch init`, or an env-configured overlay) defaults ON.
72
+ this.autoCommit = local.autoCommit !== false;
73
+ this.privateAutoCommit = !!(priv && this.autoCommit);
74
+ // Mode: no overlay → "public". With an overlay, an absent `mode` reads as "private"
75
+ // (the pre-mode behavior — split routing), so existing setups are unchanged on upgrade;
76
+ // only an explicit `hunch shared` opts a repo into unified routing.
77
+ this.mode = priv ? (local.mode ?? "private") : "public";
78
+ this.unified = this.mode === "shared" && !!this.privateJson;
79
+ }
80
+ /** Where a capture belongs: an explicit private:true always goes to the overlay
81
+ * (putPrivate throws rather than silently landing public when none is configured);
82
+ * otherwise the overlay in unified ("shared") mode, else the public store. ONE home
83
+ * per record — the single-source-of-truth contract. */
84
+ captureHome(isPrivate = false) {
85
+ if (isPrivate)
86
+ return "private";
87
+ return this.unified ? "private" : "public";
88
+ }
89
+ /** Write a capture to its ONE home (see captureHome). Every capture path — MCP tools,
90
+ * post-commit synthesis, inline intents, record-constraint/conform, runbooks — funnels
91
+ * through here so all modes, branches, worktrees, teams, and agents agree on where
92
+ * memory lives. */
93
+ putCapture(kind, record, isPrivate = false) {
94
+ return this.captureHome(isPrivate) === "private" ? this.putPrivate(kind, record) : this.json.put(kind, record);
95
+ }
96
+ /** Read a record by id from wherever it lives (private overlay wins on collision). */
97
+ getRec(kind, id) {
98
+ return this.privateJson?.get(kind, id) ?? this.json.get(kind, id);
99
+ }
100
+ /** Update an EXISTING record in the store that holds it — an overlay record must never
101
+ * fork a public copy on update (and vice versa). Falls back to captureHome routing for
102
+ * a record that exists nowhere yet. */
103
+ putWhereItLives(kind, record) {
104
+ const id = record.id;
105
+ if (this.privateJson?.get(kind, id))
106
+ return this.putPrivate(kind, record);
107
+ if (this.json.get(kind, id))
108
+ return this.json.put(kind, record);
109
+ return this.putCapture(kind, record);
58
110
  }
59
111
  /** The private-overlay config from the gitignored `.hunch/local.json` (per-machine,
60
- * never committed). Tolerant: returns {} on missing/invalid so reads never crash. */
112
+ * never committed). Tolerant: returns {} on missing/invalid so reads never crash.
113
+ * `autoCommit` is tri-state: true/false when the file says so, undefined when unset.
114
+ * `mode` records HOW the overlay was set up ("private" split vs "shared" unified). */
61
115
  localConfig() {
62
116
  const read = (file) => {
63
117
  try {
@@ -65,7 +119,8 @@ export class HunchStore {
65
119
  return {};
66
120
  const v = JSON.parse(readFileSync(file, "utf8"));
67
121
  const privateDir = typeof v.privateDir === "string" && v.privateDir.trim() ? v.privateDir.trim() : undefined;
68
- return { privateDir, autoCommit: v.autoCommit === true };
122
+ const mode = v.mode === "private" || v.mode === "shared" ? v.mode : undefined;
123
+ return { privateDir, autoCommit: typeof v.autoCommit === "boolean" ? v.autoCommit : undefined, mode };
69
124
  }
70
125
  catch {
71
126
  return {};
@@ -82,8 +137,10 @@ export class HunchStore {
82
137
  const common = gitCommonDir(this.paths.root);
83
138
  if (common) {
84
139
  const shared = read(join(common, "hunch", "local.json"));
140
+ // A per-worktree `autoCommit: false` (hunch init --no-auto-commit) is an explicit
141
+ // local opt-out — it must survive the fall-through to the shared overlay pointer.
85
142
  if (shared.privateDir)
86
- return shared;
143
+ return { ...shared, autoCommit: perWorktree.autoCommit ?? shared.autoCommit };
87
144
  }
88
145
  return perWorktree;
89
146
  }
@@ -131,7 +188,7 @@ export class HunchStore {
131
188
  reindex() {
132
189
  const db = this.db;
133
190
  const counts = {};
134
- const tx = db.transaction(() => {
191
+ withTx(db, () => {
135
192
  db.exec(RESET_SQL);
136
193
  const j = (s) => s; // readability marker for JSON-encoded columns
137
194
  // Prepare the FTS insert ONCE (after RESET created the table), not per row.
@@ -206,7 +263,6 @@ export class HunchStore {
206
263
  counts.runbooks = runbooks.length;
207
264
  void j;
208
265
  });
209
- tx();
210
266
  // Reconcile embeddings AFTER the FTS rebuild (model-free): drop vectors whose
211
267
  // source doc vanished or whose text changed. Embeddings are NOT in RESET_SQL,
212
268
  // so this is what keeps them coherent across the many reindex() call sites.
@@ -260,14 +316,13 @@ export class HunchStore {
260
316
  const rows = this.db.prepare(`SELECT ref, doc_hash FROM embeddings`).all();
261
317
  const del = this.db.prepare(`DELETE FROM embeddings WHERE ref = ?`);
262
318
  let pruned = 0;
263
- const tx = this.db.transaction(() => {
319
+ withTx(this.db, () => {
264
320
  for (const r of rows)
265
321
  if (live.get(r.ref) !== r.doc_hash) {
266
322
  del.run(r.ref);
267
323
  pruned++;
268
324
  }
269
325
  });
270
- tx();
271
326
  return pruned;
272
327
  }
273
328
  /** Embedding coverage for a model: up-to-date vectors vs total docs (doctor). */
@@ -300,7 +355,7 @@ export class HunchStore {
300
355
  for (let i = 0; i < todo.length; i += batchSize) {
301
356
  const slice = todo.slice(i, i + batchSize);
302
357
  const vecs = await embedder.embed(slice.map((d) => `${d.title}\n${d.body}`));
303
- const tx = this.db.transaction(() => {
358
+ withTx(this.db, () => {
304
359
  slice.forEach((d, j) => {
305
360
  const v = vecs[j];
306
361
  if (v) {
@@ -309,7 +364,6 @@ export class HunchStore {
309
364
  }
310
365
  });
311
366
  });
312
- tx();
313
367
  attempted += slice.length;
314
368
  opts.onProgress?.(attempted, todo.length);
315
369
  }
@@ -1110,7 +1164,7 @@ function numEnv(name, dflt) {
1110
1164
  }
1111
1165
  /** Pack a vector's exact bytes for SQLite. Explicit offset+length so a SUBARRAY
1112
1166
  * view (byteOffset != 0) writes only its slice, not the whole backing buffer.
1113
- * better-sqlite3 copies on bind, so the returned view never aliases the row. */
1167
+ * node:sqlite copies on bind, so the returned view never aliases the row. */
1114
1168
  function vecToBlob(v) {
1115
1169
  return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
1116
1170
  }
@@ -157,12 +157,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
157
157
  },
158
158
  date: meta.date, // the commit date
159
159
  };
160
- // Route to the PRIVATE overlay when asked (post-commit sync in a repo whose memory
161
- // is kept private) keeps auto-captured decisions out of the public repo entirely.
162
- if (opts.private)
163
- store.putPrivate("decisions", decision);
164
- else
165
- store.json.put("decisions", decision);
160
+ // Route to the record's ONE home: the overlay when asked (--private) or in unified
161
+ // ("shared") mode; else the public store. Same contract as every other capture path.
162
+ store.putCapture("decisions", decision, opts.private);
166
163
  return { status: "written", decision, provider: provider.name };
167
164
  }
168
165
  /** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
@@ -212,14 +209,14 @@ export async function recordFailure(store, root, failure) {
212
209
  evidence: [`test:${failure.test}`, ...affectedFiles.slice(0, 6)],
213
210
  },
214
211
  };
215
- store.json.put("bugs", bug);
212
+ store.putCapture("bugs", bug);
216
213
  // Promotion (DESIGN §4): a recurrence or a SUBSTANTIATED high-severity bug raises
217
214
  // a regression Constraint to stop it coming back, and bumps fragility.
218
215
  let constraint;
219
216
  if (shouldPromoteConstraint(draft.severity, bug.root_cause, !!prior)) {
220
217
  constraint = promoteConstraint(store, bug);
221
218
  bug.lineage.spawned_constraint = constraint.id;
222
- store.json.put("bugs", bug); // re-persist with the link
219
+ store.putWhereItLives("bugs", bug); // re-persist with the link, in the same home
223
220
  }
224
221
  raiseFragility(store, affectedFiles);
225
222
  return { status: "written", bug, constraint, provider: provider.name };
@@ -252,10 +249,10 @@ export async function captureTestRun(store, root, input) {
252
249
  catch { /* not a git repo / no HEAD — leave null */ }
253
250
  const fixed = [];
254
251
  for (const name of report.passed) {
255
- const b = store.json.get("bugs", bugId(name));
252
+ const b = store.getRec("bugs", bugId(name)); // a unified-mode bug lives in the overlay
256
253
  if (b && b.status === "open") {
257
254
  const resolved = { ...b, status: "fixed", lineage: { ...b.lineage, fixed_commit: sha } };
258
- store.json.put("bugs", resolved);
255
+ store.putWhereItLives("bugs", resolved);
259
256
  fixed.push(resolved);
260
257
  }
261
258
  }
@@ -293,7 +290,7 @@ function promoteConstraint(store, bug) {
293
290
  valid_to: null,
294
291
  provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
295
292
  };
296
- return store.json.put("constraints", con);
293
+ return store.putCapture("constraints", con);
297
294
  }
298
295
  /** Bump fragility on components owning the affected files. */
299
296
  function raiseFragility(store, files) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.39.0",
3
+ "version": "1.0.0",
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).",
@@ -35,7 +35,7 @@
35
35
  "developer-tools"
36
36
  ],
37
37
  "engines": {
38
- "node": ">=20"
38
+ "node": ">=22.13.0"
39
39
  },
40
40
  "scripts": {
41
41
  "clean": "node --input-type=commonjs -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
@@ -48,15 +48,13 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@modelcontextprotocol/sdk": "^1.29.0",
51
- "better-sqlite3": "12.9.0",
52
51
  "commander": "^15.0.0",
53
52
  "tree-sitter": "0.21.1",
54
53
  "tree-sitter-typescript": "^0.23.2",
55
54
  "zod": "^4.4.3"
56
55
  },
57
56
  "devDependencies": {
58
- "@types/better-sqlite3": "^7.6.13",
59
- "@types/node": "^20.19.0",
57
+ "@types/node": "^22.13.0",
60
58
  "tsx": "^4.22.4",
61
59
  "typescript": "^5.9.3"
62
60
  },