@davesheffer/hunch 0.16.1 → 0.17.1

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
@@ -158,6 +158,17 @@ is **OS-agnostic**: paths are stored in POSIX form and an installed Hunch regist
158
158
  server by package name, so Windows / macOS / Linux teammates share one memory without
159
159
  per-machine fixups. → [docs](https://hunch-pi.vercel.app/docs#team)
160
160
 
161
+ ## Private memory (public repo, private context)
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)
171
+
161
172
  ## Continuous learning (CI)
162
173
 
163
174
  The decision half of the loop is automatic (the post-commit hook). Light up the bug/constraint
package/dist/cli/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { execFileSync, spawnSync } from "node:child_process";
18
- import { relative } from "node:path";
18
+ import { relative, resolve } from "node:path";
19
19
  import { Command } from "commander";
20
20
  import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
21
21
  import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
@@ -64,6 +64,7 @@ program
64
64
  .option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
65
65
  .option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
66
66
  .option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
67
+ .option("--private-sync", "post-commit synthesis writes captured decisions into the private overlay (HUNCH_PRIVATE_DIR), never the public repo")
67
68
  .action((opts) => {
68
69
  // Validate --firmness up front, before any side effects (indexing, git hooks,
69
70
  // .mcp.json) or opening the store — a bad value must not leave a half-init.
@@ -92,8 +93,8 @@ program
92
93
  console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
93
94
  }
94
95
  if (isGitRepo(root)) {
95
- const h = installPostCommitHook(root, inv.shell);
96
- console.log(` ✓ post-commit hook ${h.action} (learning loop)`);
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" : ""}`);
97
98
  const m = installMergeDriver(root, inv.shell);
98
99
  console.log(` ✓ team merge driver ${m.action}`);
99
100
  // Auto-install the pre-commit guard by default (advisory: flags invariants
@@ -223,12 +224,17 @@ program
223
224
  .option("--from-hook", "invoked by the git hook")
224
225
  .option("--quiet", "minimal output")
225
226
  .option("--force", "re-synthesize even if a decision already exists for the commit")
227
+ .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")
226
228
  .action(async (sha, opts) => {
227
229
  const { store, root } = storeFor();
228
230
  if (!isGitRepo(root))
229
231
  return opts.quiet ? undefined : fail("sync needs a git repo");
232
+ if (opts.private && !store.hasPrivate) {
233
+ store.close();
234
+ return opts.quiet ? undefined : fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
235
+ }
230
236
  store.json.ensureDirs();
231
- const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force });
237
+ const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: opts.private });
232
238
  if (r.status === "written") {
233
239
  store.reindex();
234
240
  // Don't rewrite CLAUDE.md from the hook — it would dirty the working tree
@@ -552,6 +558,7 @@ program
552
558
  .option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
553
559
  .option("--format <fmt>", "output: text (default) | markdown (a PR comment)", "text")
554
560
  .option("--blast", "also print the dependency blast radius of the changed files")
561
+ .option("--public-only", "exclude the private overlay (HUNCH_PRIVATE_DIR) from the report — use for any output that may be posted publicly (the CI PR comment passes this)")
555
562
  .action((opts) => {
556
563
  const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
557
564
  if (sources.length > 1)
@@ -582,6 +589,7 @@ program
582
589
  const report = store.buildCheckReport(files, diff, {
583
590
  strict: !!opts.strict,
584
591
  lastChange: (f) => lastChangeDate(f, root),
592
+ publicOnly: !!opts.publicOnly,
585
593
  });
586
594
  if (opts.blast && !markdown) {
587
595
  console.log(`Blast radius of ${files.length} changed file(s):`);
@@ -1041,6 +1049,10 @@ program
1041
1049
  }
1042
1050
  const c = store.reindex().counts;
1043
1051
  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`));
1044
1056
  // Semantic search is opt-in and local. Report availability + coverage without
1045
1057
  // loading the model (selectEmbedder only probes; embeddingStats just counts rows).
1046
1058
  const emb = await selectEmbedder();
@@ -22,6 +22,21 @@ export function hunchPaths(root) {
22
22
  dir: (kind) => join(hunch, kind),
23
23
  };
24
24
  }
25
+ /** Build paths for a hunch-layout directory given DIRECTLY — i.e. `hunchDir` IS
26
+ * the dir holding the kind subdirs (decisions/, bugs/, …). Used for an external
27
+ * PRIVATE overlay store (HUNCH_PRIVATE_DIR), which lives in a separate repo the
28
+ * user controls rather than under the current repo's `.hunch/`. */
29
+ export function hunchPathsForDir(hunchDir) {
30
+ const hunch = resolve(hunchDir);
31
+ return {
32
+ root: dirname(hunch),
33
+ hunch,
34
+ sqlite: join(hunch, "hunch.sqlite"),
35
+ manifest: join(hunch, "manifest.json"),
36
+ config: join(hunch, "config.json"),
37
+ dir: (kind) => join(hunch, kind),
38
+ };
39
+ }
25
40
  /** Walk up from `start` to find the nearest repo containing a .hunch/ dir,
26
41
  * else the nearest git repo, else `start`. Lets `hunch` run from subdirs. */
27
42
  export function findRoot(start = process.cwd()) {
@@ -46,9 +46,14 @@ jobs:
46
46
 
47
47
  - name: Run Constraint Guard
48
48
  id: guard
49
+ # The report is posted as a public PR comment, so it MUST stay public-only:
50
+ # --public-only excludes any private overlay, and HUNCH_PRIVATE_DIR is neutralized
51
+ # here as defense-in-depth. Never wire a private memory store into CI.
52
+ env:
53
+ HUNCH_PRIVATE_DIR: ""
49
54
  run: |
50
55
  set +e
51
- hunch check --base "origin/\${{ github.base_ref }}" --strict --format markdown > hunch-report.md
56
+ hunch check --base "origin/\${{ github.base_ref }}" --strict --format markdown --public-only > hunch-report.md
52
57
  echo "exit=$?" >> "$GITHUB_OUTPUT"
53
58
  set -e
54
59
 
@@ -19,6 +19,10 @@ const ENTRIES = [
19
19
  ".hunch/*.sqlite-wal",
20
20
  ".hunch/*.sqlite-journal",
21
21
  ".hunch/**/*.tmp*",
22
+ // A local PRIVATE overlay store (HUNCH_PRIVATE_DIR) for sensitive memory — never
23
+ // committed. This is the conventional in-repo path; point the env elsewhere for a
24
+ // fully separate private repo.
25
+ ".hunch-private/",
22
26
  ];
23
27
  export function ensureGitignore(root) {
24
28
  const path = join(root, ".gitignore");
@@ -9,17 +9,21 @@ import { join, isAbsolute } from "node:path";
9
9
  import { hooksDir } from "../extractors/git.js";
10
10
  const MARK = "# >>> hunch post-commit >>>";
11
11
  const ENDMARK = "# <<< hunch post-commit <<<";
12
- function block(invocation) {
12
+ function block(invocation, opts = {}) {
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.
16
+ const priv = opts.private ? " --private" : "";
13
17
  return [
14
18
  MARK,
15
19
  'if [ -z "$HUNCH_SYNC" ]; then',
16
20
  " export HUNCH_SYNC=1",
17
- ` ( ${invocation} sync --from-hook --quiet >/dev/null 2>&1 || true ) &`,
21
+ ` ( ${invocation} sync --from-hook --quiet${priv} >/dev/null 2>&1 || true ) &`,
18
22
  "fi",
19
23
  ENDMARK,
20
24
  ].join("\n");
21
25
  }
22
- export function installPostCommitHook(root, invocation) {
26
+ export function installPostCommitHook(root, invocation, opts = {}) {
23
27
  const dir = hooksDir(root);
24
28
  // `git rev-parse --git-path hooks` returns a path relative to the repo in a
25
29
  // normal checkout, but an ABSOLUTE one inside a linked worktree (the shared
@@ -28,7 +32,7 @@ export function installPostCommitHook(root, invocation) {
28
32
  const abs = isAbsolute(dir) ? dir : join(root, dir);
29
33
  mkdirSync(abs, { recursive: true });
30
34
  const hookPath = join(abs, "post-commit");
31
- const blk = block(invocation);
35
+ const blk = block(invocation, opts);
32
36
  if (!existsSync(hookPath)) {
33
37
  writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
34
38
  chmodSync(hookPath, 0o755);
@@ -223,7 +223,7 @@ export function buildServer(root) {
223
223
  // -- hunch_record_decision (write-back) -----------------------------------
224
224
  server.registerTool("hunch_record_decision", {
225
225
  title: "Record a decision (write-back)",
226
- description: "Persist a new Decision (ADR) into Hunch with provenance. Use after making a non-trivial design choice so future sessions are grounded in it.",
226
+ description: "Persist a new Decision (ADR) into Hunch with provenance. Use after making a non-trivial design choice so future sessions are grounded in it. Set private:true to keep a SENSITIVE decision out of a (possibly public) repo — it is written to the HUNCH_PRIVATE_DIR overlay store and stays queryable locally, never committed here.",
227
227
  inputSchema: {
228
228
  decision: z.object({
229
229
  title: z.string(),
@@ -236,6 +236,7 @@ export function buildServer(root) {
236
236
  status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
237
237
  commit: z.string().optional(),
238
238
  supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
239
+ private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — for sensitive decisions kept out of a public repo. Errors if no private store is configured."),
239
240
  }),
240
241
  },
241
242
  }, async ({ decision }) => {
@@ -251,7 +252,9 @@ export function buildServer(root) {
251
252
  const id = fullSha ? decisionId(fullSha) : decisionId(`manual:${decision.title}`);
252
253
  // Preserve the ADR lineage: upgrading an auto-draft yields the composite
253
254
  // provenance the design specifies.
254
- const existing = store.json.get("decisions", id);
255
+ // Public-only lookup; skip it for a private write so a private decision never
256
+ // inherits fields from a same-id PUBLIC record (and vice-versa).
257
+ const existing = decision.private ? undefined : store.json.get("decisions", id);
255
258
  const source = existing && existing.provenance.source.includes("llm_draft")
256
259
  ? "llm_draft+human_confirmed"
257
260
  : "human_confirmed";
@@ -277,14 +280,22 @@ export function buildServer(root) {
277
280
  provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
278
281
  date: now,
279
282
  };
280
- store.json.put("decisions", rec);
281
- // Invalidate, don't delete: closing the superseded decision's valid-time
282
- // window (+ a supersedes edge) preserves the why-it-changed trail.
283
- const superseded = decision.supersedes ? store.supersede(decision.supersedes, rec) : null;
283
+ // Route the write: private records go to the HUNCH_PRIVATE_DIR overlay (never
284
+ // the committed repo); everything else to the public store. putPrivate throws
285
+ // if no private store is configured, so "private" can't silently fall public.
286
+ if (decision.private)
287
+ store.putPrivate("decisions", rec);
288
+ else
289
+ store.json.put("decisions", rec);
290
+ // Invalidate, don't delete: closing the superseded decision's valid-time window
291
+ // (+ a supersedes edge) preserves the why-it-changed trail. Supersede operates on
292
+ // the public store, so skip it for a private record (a v1 limitation, not a leak).
293
+ const superseded = decision.supersedes && !decision.private ? store.supersede(decision.supersedes, rec) : null;
284
294
  store.reindex();
285
295
  const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
286
296
  const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
287
- return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${supNote}${note}`);
297
+ const where = decision.private ? " [PRIVATE overlay — not committed to this repo]" : "";
298
+ return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}`);
288
299
  }
289
300
  catch (e) {
290
301
  return err(`Failed to record decision: ${e.message}`);
@@ -302,19 +313,26 @@ export function buildServer(root) {
302
313
  type: z.enum(["security", "performance", "correctness", "architecture", "compliance"]).optional(),
303
314
  rationale: z.string().optional().describe("Why it must hold."),
304
315
  source_decision: z.string().optional().describe("id of a decision this correction derives from."),
316
+ private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — a sensitive rule enforced locally (pre-edit hook + local check) but never exposed in a public PR comment. Errors if no private store is configured."),
305
317
  },
306
318
  }, async (input) => {
307
319
  try {
308
320
  if (!input.rule || !input.rule.trim())
309
321
  return err("rule is required — state the invariant in plain words.");
310
322
  const rec = buildCorrectionConstraint(input, new Date().toISOString());
311
- const existing = store.json.get("constraints", rec.id);
312
- store.json.put("constraints", rec);
323
+ // Private corrections go to the overlay (enforced locally via the merged read,
324
+ // never rendered into the public CI comment, which is public-only by construction).
325
+ const existing = input.private ? undefined : store.json.get("constraints", rec.id);
326
+ if (input.private)
327
+ store.putPrivate("constraints", rec);
328
+ else
329
+ store.json.put("constraints", rec);
313
330
  store.reindex();
314
331
  const enforce = rec.severity === "blocking"
315
332
  ? "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"
316
333
  : "flags violating edits and PRs (advisory)";
317
- return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}). It now ${enforce}.`);
334
+ const where = input.private ? " [PRIVATE overlay not committed to this repo]" : "";
335
+ return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.`);
318
336
  }
319
337
  catch (e) {
320
338
  return err(`Failed to record correction: ${e.message}`);
@@ -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 { toPosixTarget } from "../core/paths.js";
13
+ import { resolve } from "node:path";
14
+ import { toPosixTarget, hunchPathsForDir } from "../core/paths.js";
14
15
  import { ENTITY_KINDS } from "../core/types.js";
15
16
  import { openDb } from "./db.js";
16
17
  import { RESET_SQL, embedHash } from "./schema.js";
@@ -23,10 +24,51 @@ import { analyzeDiff } from "../extractors/diff.js";
23
24
  export class HunchStore {
24
25
  paths;
25
26
  json;
27
+ /** Optional PRIVATE overlay (HUNCH_PRIVATE_DIR) — a second store in a repo the
28
+ * user controls. Unioned into reads via recs(); never written by public paths. */
29
+ privateJson;
30
+ /** When true, recs() ignores the private overlay (public-only). Set transiently by
31
+ * buildCheckReport({publicOnly}) so any PUBLICLY-POSTED report (the CI PR comment)
32
+ * can never render a private record — a publicly-posted output is a leak surface
33
+ * equal to a committed file (dec_d7bad4ccb7). */
34
+ suppressPrivate = false;
26
35
  _db = null;
27
36
  constructor(paths) {
28
37
  this.paths = paths;
29
38
  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)));
42
+ }
43
+ /** Merged read: public ∪ private overlay (private wins on id collision). Every
44
+ * QUERY / REINDEX path uses this so MCP + the guards see private memory. Public-
45
+ * artifact writers keep using this.json.loadAll (public-only) so a private record
46
+ * can never reach a committed file — see dec_d7bad4ccb7. */
47
+ recs(kind) {
48
+ const pub = this.json.loadAll(kind);
49
+ const priv = this.suppressPrivate ? undefined : this.privateJson?.loadAll(kind);
50
+ if (!priv?.length)
51
+ return pub;
52
+ const byId = new Map();
53
+ for (const r of pub)
54
+ byId.set(r.id, r);
55
+ for (const r of priv)
56
+ byId.set(r.id, r);
57
+ return [...byId.values()];
58
+ }
59
+ /** Whether a private overlay store is configured (HUNCH_PRIVATE_DIR is set). */
60
+ get hasPrivate() {
61
+ return !!this.privateJson;
62
+ }
63
+ /** Write a record into the PRIVATE overlay (never the public repo). Throws if no
64
+ * HUNCH_PRIVATE_DIR is configured, so a "private" write can never silently land
65
+ * in the public `.hunch/`. */
66
+ putPrivate(kind, record) {
67
+ if (!this.privateJson) {
68
+ throw new Error("No private store configured — set HUNCH_PRIVATE_DIR to a directory Hunch can write sensitive records into.");
69
+ }
70
+ this.privateJson.ensureDirs();
71
+ return this.privateJson.put(kind, record);
30
72
  }
31
73
  get db() {
32
74
  if (!this._db)
@@ -50,7 +92,7 @@ export class HunchStore {
50
92
  const fts = (ref, kind, title, body) => {
51
93
  insFts.run(ref, kind, title, body ?? "");
52
94
  };
53
- const comps = this.json.loadAll("components");
95
+ const comps = this.recs("components");
54
96
  const insComp = db.prepare(`INSERT INTO components VALUES (@id,@kind,@name,@responsibility,@paths,@status,@owners,@fragility,@ps,@pc,@pe,@created_at,@updated_at)`);
55
97
  for (const c of comps) {
56
98
  insComp.run({
@@ -62,14 +104,14 @@ export class HunchStore {
62
104
  fts(c.id, "components", c.name, `${c.responsibility} ${c.paths.join(" ")}`);
63
105
  }
64
106
  counts.components = comps.length;
65
- const edges = this.json.loadAll("edges");
107
+ const edges = this.recs("edges");
66
108
  const insEdge = db.prepare(`INSERT INTO edges VALUES (@id,@from,@to,@type,@reason,@strength,@ps,@pc,@pe)`);
67
109
  for (const e of edges) {
68
110
  insEdge.run({ id: e.id, from: e.from, to: e.to, type: e.type, reason: e.reason, strength: e.strength,
69
111
  ps: e.provenance.source, pc: e.provenance.confidence, pe: JSON.stringify(e.provenance.evidence) });
70
112
  }
71
113
  counts.edges = edges.length;
72
- const syms = this.json.loadAll("symbols");
114
+ const syms = this.recs("symbols");
73
115
  const insSym = db.prepare(`INSERT INTO symbols VALUES (@id,@file,@name,@kind,@sh,@calls,@called_by,@loc,@churn,@bug,@fanin,@fanout,@last)`);
74
116
  for (const s of syms) {
75
117
  insSym.run({ id: s.id, file: s.file, name: s.name, kind: s.kind, sh: s.signature_hash,
@@ -79,7 +121,7 @@ export class HunchStore {
79
121
  fts(s.id, "symbols", `${s.name} (${s.kind})`, s.file);
80
122
  }
81
123
  counts.symbols = syms.length;
82
- const decs = this.json.loadAll("decisions");
124
+ const decs = this.recs("decisions");
83
125
  const insDec = db.prepare(`INSERT INTO decisions VALUES (@id,@title,@status,@context,@decision,@cons,@alts,@rc,@rf,@sup,@cbb,@commit,@ps,@pc,@pe,@date)`);
84
126
  for (const d of decs) {
85
127
  insDec.run({ id: d.id, title: d.title, status: d.status, context: d.context, decision: d.decision,
@@ -90,7 +132,7 @@ export class HunchStore {
90
132
  fts(d.id, "decisions", d.title, `${d.context} ${d.decision} ${d.consequences.join(" ")}`);
91
133
  }
92
134
  counts.decisions = decs.length;
93
- const bugs = this.json.loadAll("bugs");
135
+ const bugs = this.recs("bugs");
94
136
  const insBug = db.prepare(`INSERT INTO bugs VALUES (@id,@title,@symptom,@rc,@sev,@status,@af,@as,@lin,@ps,@pc,@pe)`);
95
137
  for (const b of bugs) {
96
138
  insBug.run({ id: b.id, title: b.title, symptom: b.symptom, rc: b.root_cause, sev: b.severity, status: b.status,
@@ -99,7 +141,7 @@ export class HunchStore {
99
141
  fts(b.id, "bugs", b.title, `${b.symptom} ${b.root_cause}`);
100
142
  }
101
143
  counts.bugs = bugs.length;
102
- const cons = this.json.loadAll("constraints");
144
+ const cons = this.recs("constraints");
103
145
  const insCon = db.prepare(`INSERT INTO constraints VALUES (@id,@type,@statement,@scope,@sev,@enf,@rat,@sd,@viol,@ps,@pc,@pe)`);
104
146
  for (const c of cons) {
105
147
  insCon.run({ id: c.id, type: c.type, statement: c.statement, scope: JSON.stringify(c.scope),
@@ -297,11 +339,11 @@ export class HunchStore {
297
339
  * history-inclusive view (backward-compatible default). */
298
340
  why(target, opts = {}) {
299
341
  target = toPosixTarget(target);
300
- const decisions = this.json.loadAll("decisions");
301
- const bugs = this.json.loadAll("bugs");
302
- const constraints = this.json.loadAll("constraints");
303
- const symbols = this.json.loadAll("symbols");
304
- const components = this.json.loadAll("components");
342
+ const decisions = this.recs("decisions");
343
+ const bugs = this.recs("bugs");
344
+ const constraints = this.recs("constraints");
345
+ const symbols = this.recs("symbols");
346
+ const components = this.recs("components");
305
347
  const asOf = opts.asOf;
306
348
  const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || s.file.endsWith(target));
307
349
  const symIds = new Set(matchedSymbols.map((s) => s.id));
@@ -384,7 +426,7 @@ export class HunchStore {
384
426
  * longer enforced. Pass `{ asOf }` to instead return the invariants in force at
385
427
  * that instant (time-travel: "what must I not have broken as of commit X?"). */
386
428
  checkConstraints(scope, opts = {}) {
387
- const all = this.json.loadAll("constraints");
429
+ const all = this.recs("constraints");
388
430
  const asOf = opts.asOf;
389
431
  return all
390
432
  .filter((c) => c.scope.some((g) => pathMatchesGlob(scope, g) || pathMatchesGlob(g, scope) || g === scope))
@@ -403,7 +445,7 @@ export class HunchStore {
403
445
  const dec = c.source_decision ? this.json.get("decisions", c.source_decision) : null;
404
446
  if (dec)
405
447
  out.decision = { id: dec.id, title: dec.title, decision: dec.decision };
406
- const bugs = this.json.loadAll("bugs");
448
+ const bugs = this.recs("bugs");
407
449
  // Deterministic when several bugs link one constraint (the verdict claims to be
408
450
  // deterministic): highest severity first, then lowest id — never filesystem order.
409
451
  const SEV = { critical: 3, high: 2, medium: 1, low: 0 };
@@ -420,55 +462,66 @@ export class HunchStore {
420
462
  * gate and a causal `why` citation per direct hit. Read-only — shared by
421
463
  * `hunch check`, the CI guard, and hunch_merge_verdict so they never drift. */
422
464
  buildCheckReport(files, diff, opts) {
423
- const direct = new Map();
424
- for (const f of files)
425
- for (const c of this.checkConstraints(f)) {
426
- const e = direct.get(c.id) ?? { c, files: [] };
427
- e.files.push(f);
428
- direct.set(c.id, e);
429
- }
430
- const near = new Map();
431
- for (const f of files)
432
- for (const b of this.blastRadiusFiles(f))
433
- for (const c of this.checkConstraints(b.file)) {
434
- if (direct.has(c.id))
435
- continue;
436
- const e = near.get(c.id) ?? { c, via: [] };
437
- e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
438
- near.set(c.id, e);
465
+ // publicOnly excludes the private overlay from THIS report — required for any output
466
+ // that may be posted publicly (the CI PR comment), since a posted comment is a leak
467
+ // surface equal to a committed file. Local `hunch check` / the pre-edit hook omit it
468
+ // and so still enforce private constraints. See dec_d7bad4ccb7.
469
+ const prevSuppress = this.suppressPrivate;
470
+ this.suppressPrivate = opts.publicOnly ?? false;
471
+ try {
472
+ const direct = new Map();
473
+ for (const f of files)
474
+ for (const c of this.checkConstraints(f)) {
475
+ const e = direct.get(c.id) ?? { c, files: [] };
476
+ e.files.push(f);
477
+ direct.set(c.id, e);
439
478
  }
440
- const an = analyzeDiff(diff);
441
- const regHits = this.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
442
- const staleRecords = opts.strict && opts.lastChange ? this.staleness(opts.lastChange) : [];
443
- const staleIds = new Set(staleRecords.filter((s) => s.kind === "constraint").map((s) => s.id));
444
- const staleDecisionIds = new Set(staleRecords.filter((s) => s.kind === "decision").map((s) => s.id));
445
- const vetoes = this.vetoHits(an, files, staleDecisionIds);
446
- const redundant = this.redundantSymbols(an.addedSymbols, files, {
447
- movedFrom: [...an.filesRenamed.map((r) => r.from), ...an.filesDeleted],
448
- removedNames: new Set(an.removedSymbols.map((s) => s.name)),
449
- });
450
- const directReport = [...direct.values()].map(({ c, files: fs }) => {
451
- const stale = staleIds.has(c.id);
452
- const strictBlocks = isStrictBlocker(c, stale);
479
+ const near = new Map();
480
+ for (const f of files)
481
+ for (const b of this.blastRadiusFiles(f))
482
+ for (const c of this.checkConstraints(b.file)) {
483
+ if (direct.has(c.id))
484
+ continue;
485
+ const e = near.get(c.id) ?? { c, via: [] };
486
+ e.via.push(`${f} ${b.file} (${b.via}, depth ${b.depth})`);
487
+ near.set(c.id, e);
488
+ }
489
+ const an = analyzeDiff(diff);
490
+ const regHits = this.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
491
+ const staleRecords = opts.strict && opts.lastChange ? this.staleness(opts.lastChange) : [];
492
+ const staleIds = new Set(staleRecords.filter((s) => s.kind === "constraint").map((s) => s.id));
493
+ const staleDecisionIds = new Set(staleRecords.filter((s) => s.kind === "decision").map((s) => s.id));
494
+ const vetoes = this.vetoHits(an, files, staleDecisionIds);
495
+ const redundant = this.redundantSymbols(an.addedSymbols, files, {
496
+ movedFrom: [...an.filesRenamed.map((r) => r.from), ...an.filesDeleted],
497
+ removedNames: new Set(an.removedSymbols.map((s) => s.name)),
498
+ });
499
+ const directReport = [...direct.values()].map(({ c, files: fs }) => {
500
+ const stale = staleIds.has(c.id);
501
+ const strictBlocks = isStrictBlocker(c, stale);
502
+ return {
503
+ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
504
+ files: fs, strictBlocks,
505
+ downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
506
+ why: this.causalChain(c.id),
507
+ };
508
+ });
453
509
  return {
454
- id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
455
- files: fs, strictBlocks,
456
- downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
457
- why: this.causalChain(c.id),
510
+ fileCount: files.length,
511
+ strict: opts.strict,
512
+ direct: directReport,
513
+ near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
514
+ regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
515
+ vetoes: vetoes.map((v) => ({ decision: v.decision, title: v.title, alternative: v.alternative, chosen: v.chosen, tier: v.tier, evidence: v.evidence, blocking: v.blocks })),
516
+ redundant,
517
+ strictBlockers: directReport.filter((d) => d.strictBlocks).length,
518
+ regBlocking: regHits.filter((h) => h.blocking).length,
519
+ vetoBlocking: vetoes.filter((v) => v.blocks).length,
458
520
  };
459
- });
460
- return {
461
- fileCount: files.length,
462
- strict: opts.strict,
463
- direct: directReport,
464
- near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
465
- regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
466
- vetoes: vetoes.map((v) => ({ decision: v.decision, title: v.title, alternative: v.alternative, chosen: v.chosen, tier: v.tier, evidence: v.evidence, blocking: v.blocks })),
467
- redundant,
468
- strictBlockers: directReport.filter((d) => d.strictBlocks).length,
469
- regBlocking: regHits.filter((h) => h.blocking).length,
470
- vetoBlocking: vetoes.filter((v) => v.blocks).length,
471
- };
521
+ }
522
+ finally {
523
+ this.suppressPrivate = prevSuppress;
524
+ }
472
525
  }
473
526
  /** Sprawl/"this already exists" guard (ADVISORY, never blocks). A symbol the diff
474
527
  * ADDS whose name already exists in the indexed graph in a file NOT touched by the
@@ -503,7 +556,7 @@ export class HunchStore {
503
556
  // test fixture or a separate sub-project (test/, vscode-extension/, site/) is not
504
557
  // sprawl in the source under change — different roots, different ownership.
505
558
  const roots = new Set(files.map((f) => f.split("/")[0]));
506
- const symbols = this.json.loadAll("symbols");
559
+ const symbols = this.recs("symbols");
507
560
  const out = [];
508
561
  const seen = new Set();
509
562
  for (const sc of added) {
@@ -567,9 +620,9 @@ export class HunchStore {
567
620
  if (!addedSyms.size && !addedDeps.size)
568
621
  return [];
569
622
  const fileRelevant = (related) => related.some((f) => files.some((x) => pathRelated(x, f)));
570
- const decisions = this.json.loadAll("decisions");
623
+ const decisions = this.recs("decisions");
571
624
  // decisions tied to an active blocking constraint via source_decision
572
- const blockingDec = new Set(this.json.loadAll("constraints")
625
+ const blockingDec = new Set(this.recs("constraints")
573
626
  .filter((c) => c.severity === "blocking" && c.status !== "retired" && c.source_decision)
574
627
  .map((c) => c.source_decision));
575
628
  const out = [];
@@ -613,7 +666,7 @@ export class HunchStore {
613
666
  const addedDeps = new Set(an.addedDeps);
614
667
  const out = [];
615
668
  const seen = new Set(); // dedup: one hit per decision+alternative
616
- for (const d of this.json.loadAll("decisions")) {
669
+ for (const d of this.recs("decisions")) {
617
670
  if (d.superseded_by || d.status === "superseded")
618
671
  continue; // in-force only
619
672
  const tripwires = d.rejected_tripwires ?? [];
@@ -683,7 +736,7 @@ export class HunchStore {
683
736
  * available at edit time, so this surfaces the risk as context, not a block. */
684
737
  retiredForFile(file) {
685
738
  const out = [];
686
- for (const d of this.json.loadAll("decisions")) {
739
+ for (const d of this.recs("decisions")) {
687
740
  if (d.superseded_by || d.status === "superseded")
688
741
  continue;
689
742
  if (!d.retired.symbols.length && !d.retired.deps.length)
@@ -696,7 +749,7 @@ export class HunchStore {
696
749
  }
697
750
  /** Bugs matching a symptom (FTS over bugs) or a symbol, with lineage (hunch_bug_lineage). */
698
751
  bugLineage(symptomOrSymbol) {
699
- const bugs = this.json.loadAll("bugs");
752
+ const bugs = this.recs("bugs");
700
753
  const direct = bugs.filter((b) => b.affected_symbols.includes(symptomOrSymbol) || b.affected_files.includes(symptomOrSymbol));
701
754
  if (direct.length)
702
755
  return direct;
@@ -711,8 +764,8 @@ export class HunchStore {
711
764
  }
712
765
  /** Ranked fragility report (hunch fragile). fragility = weighted churn + bugs + fan-in. */
713
766
  fragility(limit = 15) {
714
- const syms = this.json.loadAll("symbols");
715
- const bugs = this.json.loadAll("bugs");
767
+ const syms = this.recs("symbols");
768
+ const bugs = this.recs("bugs");
716
769
  // bug counts per symbol from actual bug records (authoritative over stale metric)
717
770
  const bugBySym = new Map();
718
771
  for (const b of bugs)
@@ -749,7 +802,7 @@ export class HunchStore {
749
802
  }
750
803
  /** All edges (for graph export). */
751
804
  allEdges() {
752
- return this.json.loadAll("edges");
805
+ return this.recs("edges");
753
806
  }
754
807
  /** Drift detection (DESIGN §9 "staleness kills trust"): a decision/constraint
755
808
  * is STALE when a file in its scope changed AFTER it was last verified. The
@@ -771,9 +824,9 @@ export class HunchStore {
771
824
  if (newest)
772
825
  out.push({ kind, id, last_verified: verified, changed_at: newest, files: files.slice(0, 8) });
773
826
  };
774
- for (const d of this.json.loadAll("decisions"))
827
+ for (const d of this.recs("decisions"))
775
828
  check("decision", d.id, d.related_files, d.provenance.last_verified);
776
- for (const c of this.json.loadAll("constraints"))
829
+ for (const c of this.recs("constraints"))
777
830
  check("constraint", c.id, c.scope, c.provenance.last_verified);
778
831
  return out.sort((a, b) => b.changed_at.localeCompare(a.changed_at));
779
832
  }
@@ -119,7 +119,12 @@ export async function syncCommit(store, root, sha, opts = {}) {
119
119
  },
120
120
  date: meta.date, // the commit date
121
121
  };
122
- store.json.put("decisions", decision);
122
+ // Route to the PRIVATE overlay when asked (post-commit sync in a repo whose memory
123
+ // is kept private) — keeps auto-captured decisions out of the public repo entirely.
124
+ if (opts.private)
125
+ store.putPrivate("decisions", decision);
126
+ else
127
+ store.json.put("decisions", decision);
123
128
  return { status: "written", decision, provider: provider.name };
124
129
  }
125
130
  /** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.16.1",
3
+ "version": "0.17.1",
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.",