@davesheffer/hunch 0.13.0 → 0.14.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
@@ -220,6 +220,31 @@ repo-wide (`**`) rule is only blocking when you pass `applies_to_all`, so one co
220
220
  can't silently gate the whole tree. Because it's the *same* constraint machinery, a
221
221
  correction is enforced exactly like a hand-authored invariant — see firmness above.
222
222
 
223
+ ## Causal Merge Verdict: does this change re-open a closed bug?
224
+
225
+ A diff-only reviewer (CodeRabbit, Greptile) sees *what* changed. It can't see that the line
226
+ you're deleting is the fix for an incident, or that the symbol you're re-adding was
227
+ deliberately retired. Hunch can — because it holds the **why**.
228
+
229
+ `hunch_merge_verdict` (MCP tool) and `hunch check` replay a diff against the graph and return
230
+ one verdict — **BLOCK / WARN / PASS** — that *cites the reasoning*, not just the rule:
231
+
232
+ ```text
233
+ VERDICT: ⛔ BLOCK — this change breaks a recorded invariant or re-opens a known bug.
234
+
235
+ ⛔ pay() must verify the session before charging — con_pay
236
+ 🧠 why: "Charge must verify the session first" (dec_pay)
237
+ 🐞 guards against: Double-charge on unverified session — pay() charged without verifying (bug_trunc)
238
+ ```
239
+
240
+ It's **deterministic** (no LLM): for every invariant *directly* in scope it walks
241
+ constraint → `source_decision` → the bug whose root cause spawned it; it flags invariants
242
+ reached transitively (blast radius, advisory) and any deliberately-retired code the diff
243
+ re-introduces. BLOCK fires only on a direct, high-confidence, non-stale **blocking** invariant
244
+ or a blocking-linked regression — near-hits stay advisory, so it's safe as a merge gate. Call
245
+ it before opening a PR (`{}` checks staged changes; pass `base: "origin/main"` for the range);
246
+ the CI Constraint Guard renders the same cited verdict as a PR comment.
247
+
223
248
  ## Semantic search (optional)
224
249
 
225
250
  By default `hunch query` and the `hunch_query` MCP tool use fast keyword (FTS) search —
package/dist/cli/index.js CHANGED
@@ -26,8 +26,6 @@ import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesi
26
26
  import { parseTestReport } from "../extractors/testreport.js";
27
27
  import { selectProvider } from "../synthesis/provider.js";
28
28
  import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists } from "../extractors/git.js";
29
- import { analyzeDiff } from "../extractors/diff.js";
30
- import { isStrictBlocker } from "../core/strictgate.js";
31
29
  import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
32
30
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
33
31
  import { installMergeDriver } from "../integrations/mergeDriver.js";
@@ -566,52 +564,14 @@ program
566
564
  store.close();
567
565
  return;
568
566
  }
569
- // 1) DIRECT a changed file matches a constraint's scope.
570
- const direct = new Map();
571
- for (const f of files)
572
- for (const c of store.checkConstraints(f)) {
573
- const e = direct.get(c.id) ?? { c, files: [] };
574
- e.files.push(f);
575
- direct.set(c.id, e);
576
- }
577
- // 2) NEAR — reached only through the blast radius (a guarded dependency changed).
578
- const near = new Map();
579
- for (const f of files)
580
- for (const b of store.blastRadiusFiles(f))
581
- for (const c of store.checkConstraints(b.file)) {
582
- if (direct.has(c.id))
583
- continue; // already a direct hit
584
- const e = near.get(c.id) ?? { c, via: [] };
585
- e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
586
- near.set(c.id, e);
587
- }
588
- // 3) REGRESSION — does the diff RE-ADD something an in-force decision retired?
567
+ // DIRECT (scope match) + NEAR (blast radius) + REGRESSION (re-added retired
568
+ // code) + the hardened strict gate + causal `why` citations — all assembled by
569
+ // the shared store.buildCheckReport (also used by the hunch_merge_verdict tool).
589
570
  const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
590
- const an = analyzeDiff(diff);
591
- const regHits = store.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
592
- // Hardened strict gate (strictgate.ts): only DIRECT + high-confidence + non-stale
593
- // can fail. near/stale/low-confidence stay advisory — safe on a shared repo / PR.
594
- const staleConstraintIds = opts.strict
595
- ? new Set(store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint").map((s) => s.id))
596
- : new Set();
597
- const directReport = [...direct.values()].map(({ c, files: fs }) => {
598
- const stale = staleConstraintIds.has(c.id);
599
- const strictBlocks = isStrictBlocker(c, stale);
600
- return {
601
- id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
602
- files: fs, strictBlocks,
603
- downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
604
- };
605
- });
606
- const report = {
607
- fileCount: files.length,
571
+ const report = store.buildCheckReport(files, diff, {
608
572
  strict: !!opts.strict,
609
- direct: directReport,
610
- near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
611
- regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
612
- strictBlockers: directReport.filter((d) => d.strictBlocks).length,
613
- regBlocking: regHits.filter((h) => h.blocking).length,
614
- };
573
+ lastChange: (f) => lastChangeDate(f, root),
574
+ });
615
575
  if (opts.blast && !markdown) {
616
576
  console.log(`Blast radius of ${files.length} changed file(s):`);
617
577
  for (const f of files) {
@@ -11,6 +11,36 @@ export function reportFailsStrict(r) {
11
11
  return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0);
12
12
  }
13
13
  const mark = (s) => (s === "blocking" ? "⛔" : s === "warning" ? "⚠" : "·");
14
+ const clip = (s, n = 160) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
15
+ /** The deterministic VERDICT for a merge: block (a hard gate fired), warn (touches
16
+ * memory but nothing hard-blocks), or pass (touches no recorded memory at all). */
17
+ export function verdict(r) {
18
+ if (r.strictBlockers > 0 || r.regBlocking > 0)
19
+ return "block";
20
+ return reportIsClean(r) ? "pass" : "warn";
21
+ }
22
+ /** Causal "why" citation, terminal form (appended to a direct hit). */
23
+ function whyText(why) {
24
+ if (!why)
25
+ return "";
26
+ const out = [];
27
+ if (why.decision)
28
+ out.push(`\n ↳ why: “${why.decision.title}” (${why.decision.id})${why.decision.decision ? ` — ${clip(why.decision.decision)}` : ""}`);
29
+ if (why.bug)
30
+ out.push(`\n ↳ guards against: ${why.bug.title} — ${clip(why.bug.root_cause)} (${why.bug.id})`);
31
+ return out.join("");
32
+ }
33
+ /** Causal "why" citation, markdown form (returns bullet lines). */
34
+ function whyMd(why) {
35
+ if (!why)
36
+ return [];
37
+ const out = [];
38
+ if (why.decision)
39
+ out.push(` - 🧠 _why:_ “${why.decision.title}” (\`${why.decision.id}\`)`);
40
+ if (why.bug)
41
+ out.push(` - 🐞 _guards against:_ ${clip(why.bug.title, 100)} — ${clip(why.bug.root_cause)} (\`${why.bug.id}\`)`);
42
+ return out;
43
+ }
14
44
  // ---------------------------------------------------------------------------
15
45
  // Terminal text (unchanged from the inline CLI output it replaces)
16
46
  // ---------------------------------------------------------------------------
@@ -25,7 +55,7 @@ export function renderText(r) {
25
55
  const note = r.strict && c.severity === "blocking" && !c.strictBlocks
26
56
  ? c.downgrade === "stale" ? " (advisory: stale)" : " (advisory: low confidence)"
27
57
  : "";
28
- out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}`);
58
+ out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}${whyText(c.why)}`);
29
59
  }
30
60
  }
31
61
  if (r.near.length) {
@@ -74,6 +104,8 @@ export function renderMarkdown(r) {
74
104
  out.push(` - in: ${c.files.map((f) => `\`${f}\``).join(", ")}`);
75
105
  if (c.rationale)
76
106
  out.push(` - _${c.rationale}_`);
107
+ for (const line of whyMd(c.why))
108
+ out.push(line);
77
109
  }
78
110
  out.push("");
79
111
  }
@@ -14,8 +14,9 @@ import { HunchStore } from "../store/hunchStore.js";
14
14
  import { selectEmbedder } from "../store/embedder.js";
15
15
  import { decisionId } from "../core/ids.js";
16
16
  import { buildCorrectionConstraint } from "../core/correction.js";
17
- import { revParse, asOfDate } from "../extractors/git.js";
17
+ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff } from "../extractors/git.js";
18
18
  import { formatContext } from "../core/format.js";
19
+ import { renderMarkdown, verdict } from "../core/checkreport.js";
19
20
  const ok = (text) => ({ content: [{ type: "text", text }] });
20
21
  const err = (text) => ({ content: [{ type: "text", text }], isError: true });
21
22
  // Read-side token budgets: every tool result is injected into a Claude Code
@@ -317,6 +318,40 @@ export function buildServer(root) {
317
318
  return err(`Failed to record correction: ${e.message}`);
318
319
  }
319
320
  });
321
+ // -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
322
+ server.registerTool("hunch_merge_verdict", {
323
+ title: "Causal merge verdict: is this change safe against the recorded WHY?",
324
+ description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory) and any deliberately-retired code the diff re-introduces. Deterministic, no LLM. Omit base AND commit to check STAGED changes; pass base (e.g. origin/main) for a PR range, or commit for a single commit. Call this before merging a widely-scoped change.",
325
+ inputSchema: {
326
+ base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
327
+ commit: z.string().optional().describe("Diff a single commit (sha/ref). Omit base AND commit to check staged changes."),
328
+ },
329
+ }, async ({ base, commit }) => {
330
+ try {
331
+ if (base && commit)
332
+ return err("Pass at most one of base/commit (omit both to check staged changes).");
333
+ if (base && !revExists(base, root))
334
+ return err(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
335
+ if (commit && !revExists(commit, root))
336
+ return err(`commit "${commit}" does not resolve.`);
337
+ const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
338
+ const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : "staged changes";
339
+ if (!files.length)
340
+ return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
341
+ const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
342
+ const report = store.buildCheckReport(files, diff, { strict: true, lastChange: (f) => lastChangeDate(f, root) });
343
+ const v = verdict(report);
344
+ const head = v === "block"
345
+ ? "VERDICT: ⛔ BLOCK — this change breaks a recorded invariant or re-opens a known bug."
346
+ : v === "warn"
347
+ ? "VERDICT: ⚠ WARN — this change touches engineering memory; review the cited why below before merge."
348
+ : "VERDICT: ✅ PASS — touches no recorded invariants and re-introduces nothing deliberately retired.";
349
+ return ok(`${head}\n(scope: ${scope}, ${files.length} file(s))\n\n${renderMarkdown(report)}`);
350
+ }
351
+ catch (e) {
352
+ return err(`Failed to compute merge verdict: ${e.message}`);
353
+ }
354
+ });
320
355
  return server;
321
356
  }
322
357
  function provLine(record) {
@@ -18,6 +18,8 @@ import { selectEmbedder } from "./embedder.js";
18
18
  import { JsonStore } from "./jsonStore.js";
19
19
  import { pathMatchesGlob } from "../core/glob.js";
20
20
  import { edgeId } from "../core/ids.js";
21
+ import { isStrictBlocker } from "../core/strictgate.js";
22
+ import { analyzeDiff } from "../extractors/diff.js";
21
23
  export class HunchStore {
22
24
  paths;
23
25
  json;
@@ -389,6 +391,77 @@ export class HunchStore {
389
391
  .filter((c) => (asOf ? inWindow(c.valid_from, c.valid_to, asOf) : c.status !== "retired"))
390
392
  .sort((a, b) => sev(b.severity) - sev(a.severity));
391
393
  }
394
+ /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
395
+ * Deterministic graph join: constraint → source_decision (the decision that
396
+ * motivated the guard) → the bug whose root cause spawned it (via
397
+ * lineage.spawned_constraint, else the source decision's caused_by_bug). Read-only. */
398
+ causalChain(constraintId) {
399
+ const out = { constraint_id: constraintId };
400
+ const c = this.json.get("constraints", constraintId);
401
+ if (!c)
402
+ return out;
403
+ const dec = c.source_decision ? this.json.get("decisions", c.source_decision) : null;
404
+ if (dec)
405
+ out.decision = { id: dec.id, title: dec.title, decision: dec.decision };
406
+ const bugs = this.json.loadAll("bugs");
407
+ // Deterministic when several bugs link one constraint (the verdict claims to be
408
+ // deterministic): highest severity first, then lowest id — never filesystem order.
409
+ const SEV = { critical: 3, high: 2, medium: 1, low: 0 };
410
+ const linked = bugs
411
+ .filter((b) => b.lineage?.spawned_constraint === constraintId)
412
+ .sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || a.id.localeCompare(b.id));
413
+ const bug = linked[0] ?? (dec?.caused_by_bug ? bugs.find((b) => b.id === dec.caused_by_bug) : undefined);
414
+ if (bug)
415
+ out.bug = { id: bug.id, title: bug.title, root_cause: bug.root_cause };
416
+ return out;
417
+ }
418
+ /** Assemble a CheckReport from a diff: direct invariant hits, near hits (blast
419
+ * radius), and regressions (re-added retired code), with the hardened strict
420
+ * gate and a causal `why` citation per direct hit. Read-only — shared by
421
+ * `hunch check`, the CI guard, and hunch_merge_verdict so they never drift. */
422
+ 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);
439
+ }
440
+ const an = analyzeDiff(diff);
441
+ const regHits = this.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
442
+ const staleIds = opts.strict && opts.lastChange
443
+ ? new Set(this.staleness(opts.lastChange).filter((s) => s.kind === "constraint").map((s) => s.id))
444
+ : new Set();
445
+ const directReport = [...direct.values()].map(({ c, files: fs }) => {
446
+ const stale = staleIds.has(c.id);
447
+ const strictBlocks = isStrictBlocker(c, stale);
448
+ return {
449
+ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
450
+ files: fs, strictBlocks,
451
+ downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
452
+ why: this.causalChain(c.id),
453
+ };
454
+ });
455
+ return {
456
+ fileCount: files.length,
457
+ strict: opts.strict,
458
+ direct: directReport,
459
+ near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
460
+ regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
461
+ strictBlockers: directReport.filter((d) => d.strictBlocks).length,
462
+ regBlocking: regHits.filter((h) => h.blocking).length,
463
+ };
464
+ }
392
465
  /** Time-travel: the decision history for a target — every decision touching it,
393
466
  * newest-first, with its valid-time window and supersession links. Answers
394
467
  * "what did we believe, and when/why did it change?" (hunch_timeline). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
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.",