@davesheffer/hunch 0.16.1 → 0.17.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
@@ -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";
@@ -552,6 +552,7 @@ program
552
552
  .option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
553
553
  .option("--format <fmt>", "output: text (default) | markdown (a PR comment)", "text")
554
554
  .option("--blast", "also print the dependency blast radius of the changed files")
555
+ .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
556
  .action((opts) => {
556
557
  const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
557
558
  if (sources.length > 1)
@@ -582,6 +583,7 @@ program
582
583
  const report = store.buildCheckReport(files, diff, {
583
584
  strict: !!opts.strict,
584
585
  lastChange: (f) => lastChangeDate(f, root),
586
+ publicOnly: !!opts.publicOnly,
585
587
  });
586
588
  if (opts.blast && !markdown) {
587
589
  console.log(`Blast radius of ${files.length} changed file(s):`);
@@ -1041,6 +1043,10 @@ program
1041
1043
  }
1042
1044
  const c = store.reindex().counts;
1043
1045
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
1046
+ const privDir = process.env.HUNCH_PRIVATE_DIR?.trim();
1047
+ console.log(privDir
1048
+ ? `private: on → ${resolve(privDir)} (local overlay — unioned into queries; never committed or posted publicly)`
1049
+ : dim(`private: off — set HUNCH_PRIVATE_DIR to overlay a separate private memory repo for sensitive records`));
1044
1050
  // Semantic search is opt-in and local. Report availability + coverage without
1045
1051
  // loading the model (selectEmbedder only probes; embeddingStats just counts rows).
1046
1052
  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");
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
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.",