@davesheffer/hunch 0.38.3 → 0.40.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.
@@ -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
  }
@@ -767,6 +824,15 @@ export class HunchStore {
767
824
  supersede(oldId, by) {
768
825
  return this.supersedeIn(this.json, oldId, by);
769
826
  }
827
+ /** Look up a decision by id in a SPECIFIC store — the public store, or the private
828
+ * overlay when `priv` is true — NOT the union. The capture guard uses this to know
829
+ * whether a supersede will actually close its target: `supersede`/`supersedePrivate`
830
+ * each look in only one store, so a cross-store supersede silently no-ops and would
831
+ * leave two live decisions on one topic. Returns undefined if absent (or no overlay). */
832
+ decisionInStore(id, priv) {
833
+ const store = priv ? this.privateJson : this.json;
834
+ return store?.get("decisions", id);
835
+ }
770
836
  /** Private-overlay counterpart of `supersede`: close + link the old decision inside
771
837
  * the HUNCH_PRIVATE_DIR store, so a PRIVATE decision can supersede another private
772
838
  * one (the MCP record path is private→private). A private write never mutates the
@@ -121,6 +121,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
121
121
  const decision = {
122
122
  id,
123
123
  title: draft.title,
124
+ // Auto-synthesized decisions are un-anchored (topic null) — a topic is a human
125
+ // act, never a machine guess. Preserve one an earlier human capture attached.
126
+ topic: existing?.topic ?? null,
124
127
  status: existing?.status === "accepted" ? "accepted" : "proposed",
125
128
  context: draft.context + constraintNote,
126
129
  decision: draft.decision,
@@ -154,12 +157,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
154
157
  },
155
158
  date: meta.date, // the commit date
156
159
  };
157
- // Route to the PRIVATE overlay when asked (post-commit sync in a repo whose memory
158
- // is kept private) keeps auto-captured decisions out of the public repo entirely.
159
- if (opts.private)
160
- store.putPrivate("decisions", decision);
161
- else
162
- 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);
163
163
  return { status: "written", decision, provider: provider.name };
164
164
  }
165
165
  /** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
@@ -209,14 +209,14 @@ export async function recordFailure(store, root, failure) {
209
209
  evidence: [`test:${failure.test}`, ...affectedFiles.slice(0, 6)],
210
210
  },
211
211
  };
212
- store.json.put("bugs", bug);
212
+ store.putCapture("bugs", bug);
213
213
  // Promotion (DESIGN §4): a recurrence or a SUBSTANTIATED high-severity bug raises
214
214
  // a regression Constraint to stop it coming back, and bumps fragility.
215
215
  let constraint;
216
216
  if (shouldPromoteConstraint(draft.severity, bug.root_cause, !!prior)) {
217
217
  constraint = promoteConstraint(store, bug);
218
218
  bug.lineage.spawned_constraint = constraint.id;
219
- store.json.put("bugs", bug); // re-persist with the link
219
+ store.putWhereItLives("bugs", bug); // re-persist with the link, in the same home
220
220
  }
221
221
  raiseFragility(store, affectedFiles);
222
222
  return { status: "written", bug, constraint, provider: provider.name };
@@ -249,10 +249,10 @@ export async function captureTestRun(store, root, input) {
249
249
  catch { /* not a git repo / no HEAD — leave null */ }
250
250
  const fixed = [];
251
251
  for (const name of report.passed) {
252
- const b = store.json.get("bugs", bugId(name));
252
+ const b = store.getRec("bugs", bugId(name)); // a unified-mode bug lives in the overlay
253
253
  if (b && b.status === "open") {
254
254
  const resolved = { ...b, status: "fixed", lineage: { ...b.lineage, fixed_commit: sha } };
255
- store.json.put("bugs", resolved);
255
+ store.putWhereItLives("bugs", resolved);
256
256
  fixed.push(resolved);
257
257
  }
258
258
  }
@@ -290,7 +290,7 @@ function promoteConstraint(store, bug) {
290
290
  valid_to: null,
291
291
  provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
292
292
  };
293
- return store.json.put("constraints", con);
293
+ return store.putCapture("constraints", con);
294
294
  }
295
295
  /** Bump fragility on components owning the affected files. */
296
296
  function raiseFragility(store, files) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.38.3",
3
+ "version": "0.40.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).",