@davesheffer/hunch 0.13.0 → 0.14.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
@@ -109,8 +109,10 @@ The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
109
109
  `hunch_check_constraints`, `hunch_get_dependents` (blast radius), `hunch_blast_radius`
110
110
  (dependent files + near-violations a change could break indirectly), `hunch_bug_lineage`,
111
111
  `hunch_context` (surgical minimal slice for a task), `hunch_timeline` (a target's decision
112
- history over time), `hunch_record_decision` (write-back). `hunch_why` and `hunch_context`
113
- take an optional `as_of` (commit/tag/branch) to **time-travel** the graph to a past state.
112
+ history over time), `hunch_merge_verdict` (a cited BLOCK/WARN/PASS over a diff — see below),
113
+ and the write-backs `hunch_record_decision`, `hunch_record_correction` (turn a correction into
114
+ an enforced invariant — see below). `hunch_why` and `hunch_context` take an optional `as_of`
115
+ (commit/tag/branch) to **time-travel** the graph to a past state.
114
116
 
115
117
  ### Works with any MCP assistant
116
118
 
@@ -220,6 +222,31 @@ repo-wide (`**`) rule is only blocking when you pass `applies_to_all`, so one co
220
222
  can't silently gate the whole tree. Because it's the *same* constraint machinery, a
221
223
  correction is enforced exactly like a hand-authored invariant — see firmness above.
222
224
 
225
+ ## Causal Merge Verdict: does this change re-open a closed bug?
226
+
227
+ A diff-only reviewer (CodeRabbit, Greptile) sees *what* changed. It can't see that the line
228
+ you're deleting is the fix for an incident, or that the symbol you're re-adding was
229
+ deliberately retired. Hunch can — because it holds the **why**.
230
+
231
+ `hunch_merge_verdict` (MCP tool) and `hunch check` replay a diff against the graph and return
232
+ one verdict — **BLOCK / WARN / PASS** — that *cites the reasoning*, not just the rule:
233
+
234
+ ```text
235
+ VERDICT: ⛔ BLOCK — this change breaks a recorded invariant or re-opens a known bug.
236
+
237
+ ⛔ pay() must verify the session before charging — con_pay
238
+ 🧠 why: "Charge must verify the session first" (dec_pay)
239
+ 🐞 guards against: Double-charge on unverified session — pay() charged without verifying (bug_trunc)
240
+ ```
241
+
242
+ It's **deterministic** (no LLM): for every invariant *directly* in scope it walks
243
+ constraint → `source_decision` → the bug whose root cause spawned it; it flags invariants
244
+ reached transitively (blast radius, advisory) and any deliberately-retired code the diff
245
+ re-introduces. BLOCK fires only on a direct, high-confidence, non-stale **blocking** invariant
246
+ or a blocking-linked regression — near-hits stay advisory, so it's safe as a merge gate. Call
247
+ it before opening a PR (`{}` checks staged changes; pass `base: "origin/main"` for the range);
248
+ the CI Constraint Guard renders the same cited verdict as a PR comment.
249
+
223
250
  ## Semantic search (optional)
224
251
 
225
252
  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). */
@@ -177,6 +177,18 @@ class CliSynthProvider {
177
177
  return draft;
178
178
  }
179
179
  }
180
+ // A model id comes from a HUNCH_*_MODEL env var and ends up as an argv token that,
181
+ // on Windows, pexecIn joins into the cmd.exe line (shell:true, to resolve the npm
182
+ // `.cmd` shim). The untrusted prompt always travels via stdin — never argv — so the
183
+ // ONLY non-literal token reaching that shell line is this model id. Reject anything
184
+ // with whitespace or a cmd.exe metacharacter so a poisoned env var can't smuggle
185
+ // `& evil.exe` into the line; fall back to the provider default rather than crash on
186
+ // a mere typo. (pexecIn itself stays general — callers may legitimately pass a
187
+ // pre-quoted path token — so the guard lives here, at the untrusted-input source.)
188
+ const MODEL_RE = /^[A-Za-z0-9._:/-]+$/;
189
+ export function safeModel(v, fallback) {
190
+ return v && MODEL_RE.test(v) ? v : fallback;
191
+ }
180
192
  // --------------------------------------------------------------------------
181
193
  // Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
182
194
  // --------------------------------------------------------------------------
@@ -184,7 +196,7 @@ class ClaudeCliProvider extends CliSynthProvider {
184
196
  name = "claude-cli";
185
197
  // Default to the `haiku` alias (cheap/fast, and survives model retirements)
186
198
  // rather than a pinned dated id; override with HUNCH_SYNTH_MODEL if needed.
187
- model = process.env.HUNCH_SYNTH_MODEL || "haiku";
199
+ model = safeModel(process.env.HUNCH_SYNTH_MODEL, "haiku");
188
200
  async available() {
189
201
  try {
190
202
  await pexecIn("claude", ["--version"], { timeout: 8000 });
@@ -242,7 +254,7 @@ class ClaudeCliProvider extends CliSynthProvider {
242
254
  // --------------------------------------------------------------------------
243
255
  class CodexCliProvider extends CliSynthProvider {
244
256
  name = "codex-cli";
245
- model = process.env.HUNCH_CODEX_MODEL; // omit → codex uses its configured default
257
+ model = safeModel(process.env.HUNCH_CODEX_MODEL, undefined); // omit → codex uses its configured default
246
258
  async available() {
247
259
  try {
248
260
  await pexecIn("codex", ["--version"], { timeout: 8000 });
@@ -266,7 +278,7 @@ class CodexCliProvider extends CliSynthProvider {
266
278
  // --------------------------------------------------------------------------
267
279
  class CursorCliProvider extends CliSynthProvider {
268
280
  name = "cursor-agent";
269
- model = process.env.HUNCH_CURSOR_MODEL;
281
+ model = safeModel(process.env.HUNCH_CURSOR_MODEL, undefined);
270
282
  async available() {
271
283
  try {
272
284
  await pexecIn("cursor-agent", ["--version"], { timeout: 8000 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
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.",
@@ -20,7 +20,8 @@
20
20
  "dist/**/*.js"
21
21
  ],
22
22
  "publishConfig": {
23
- "access": "public"
23
+ "access": "public",
24
+ "provenance": true
24
25
  },
25
26
  "keywords": [
26
27
  "claude-code",