@davesheffer/hunch 0.12.2 → 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
@@ -195,6 +195,56 @@ The hook never breaks your flow: any error or unrecognized input emits nothing a
195
195
  none, every level degrades to context-only. Opt out of the hooks entirely with `hunch init
196
196
  --no-agent-hooks`.
197
197
 
198
+ ## Never Twice: corrections become enforced invariants
199
+
200
+ The most expensive failure in AI coding is being corrected and then *re-corrected* — you
201
+ tell the agent "no, never call the pay-per-token API here," it complies once, and next
202
+ session it does it again because the feedback was stored as advisory text, not enforced.
203
+
204
+ Hunch closes that loop. When you correct the agent, it captures the rule as a **first-class
205
+ Constraint** (provenance `human_confirmed`) via the `hunch_record_correction` MCP tool — and
206
+ from then on the **same pre-edit hook + CI Constraint Guard** hold *every* assistant to it:
207
+
208
+ ```text
209
+ You: "no — never import lodash, we ship our own utils"
210
+ Agent: calls hunch_record_correction({ rule: "never import lodash; use src/utils",
211
+ scope_hint_file: "src/cart.ts", severity: "blocking" })
212
+ → con_… recorded. A later edit that adds `import _ from "lodash"` to that scope is DENIED
213
+ (strict firmness) and the PR fails CI — in Cursor, Copilot, Windsurf, or Claude Code alike.
214
+ ```
215
+
216
+ The `UserPromptSubmit` hook nudges the agent to persist a rule whenever your prompt reads
217
+ like a correction ("no…", "that's wrong", "never do X"), so capture is one frictionless step
218
+ rather than a discipline. Scoping is conservative by default (the file you were in); a
219
+ repo-wide (`**`) rule is only blocking when you pass `applies_to_all`, so one correction
220
+ can't silently gate the whole tree. Because it's the *same* constraint machinery, a
221
+ correction is enforced exactly like a hand-authored invariant — see firmness above.
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
+
198
248
  ## Semantic search (optional)
199
249
 
200
250
  By default `hunch query` and the `hunch_query` MCP tool use fast keyword (FTS) search —
package/dist/cli/index.js CHANGED
@@ -18,6 +18,7 @@ import { execFileSync, spawnSync } from "node:child_process";
18
18
  import { relative } from "node:path";
19
19
  import { Command } from "commander";
20
20
  import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
21
+ import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
21
22
  import { HunchStore } from "../store/hunchStore.js";
22
23
  import { selectEmbedder } from "../store/embedder.js";
23
24
  import { indexRepo } from "../extractors/indexer.js";
@@ -25,8 +26,6 @@ import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesi
25
26
  import { parseTestReport } from "../extractors/testreport.js";
26
27
  import { selectProvider } from "../synthesis/provider.js";
27
28
  import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists } from "../extractors/git.js";
28
- import { analyzeDiff } from "../extractors/diff.js";
29
- import { isStrictBlocker } from "../core/strictgate.js";
30
29
  import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
31
30
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
32
31
  import { installMergeDriver } from "../integrations/mergeDriver.js";
@@ -565,52 +564,14 @@ program
565
564
  store.close();
566
565
  return;
567
566
  }
568
- // 1) DIRECT a changed file matches a constraint's scope.
569
- const direct = new Map();
570
- for (const f of files)
571
- for (const c of store.checkConstraints(f)) {
572
- const e = direct.get(c.id) ?? { c, files: [] };
573
- e.files.push(f);
574
- direct.set(c.id, e);
575
- }
576
- // 2) NEAR — reached only through the blast radius (a guarded dependency changed).
577
- const near = new Map();
578
- for (const f of files)
579
- for (const b of store.blastRadiusFiles(f))
580
- for (const c of store.checkConstraints(b.file)) {
581
- if (direct.has(c.id))
582
- continue; // already a direct hit
583
- const e = near.get(c.id) ?? { c, via: [] };
584
- e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
585
- near.set(c.id, e);
586
- }
587
- // 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).
588
570
  const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
589
- const an = analyzeDiff(diff);
590
- const regHits = store.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
591
- // Hardened strict gate (strictgate.ts): only DIRECT + high-confidence + non-stale
592
- // can fail. near/stale/low-confidence stay advisory — safe on a shared repo / PR.
593
- const staleConstraintIds = opts.strict
594
- ? new Set(store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint").map((s) => s.id))
595
- : new Set();
596
- const directReport = [...direct.values()].map(({ c, files: fs }) => {
597
- const stale = staleConstraintIds.has(c.id);
598
- const strictBlocks = isStrictBlocker(c, stale);
599
- return {
600
- id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
601
- files: fs, strictBlocks,
602
- downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
603
- };
604
- });
605
- const report = {
606
- fileCount: files.length,
571
+ const report = store.buildCheckReport(files, diff, {
607
572
  strict: !!opts.strict,
608
- direct: directReport,
609
- near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
610
- regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
611
- strictBlockers: directReport.filter((d) => d.strictBlocks).length,
612
- regBlocking: regHits.filter((h) => h.blocking).length,
613
- };
573
+ lastChange: (f) => lastChangeDate(f, root),
574
+ });
614
575
  if (opts.blast && !markdown) {
615
576
  console.log(`Blast radius of ${files.length} changed file(s):`);
616
577
  for (const f of files) {
@@ -736,7 +697,11 @@ program
736
697
  if (firmness === "off")
737
698
  return;
738
699
  if (evt.hook_event_name === "UserPromptSubmit") {
739
- emitContext("UserPromptSubmit", HOOK_REMINDER);
700
+ // When the prompt reads like a correction ("no / that's wrong / never X"),
701
+ // nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
702
+ // not just obey it this once and forget it next session.
703
+ const text = looksLikeCorrection(evt.prompt) ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
704
+ emitContext("UserPromptSubmit", text);
740
705
  return;
741
706
  }
742
707
  if (evt.hook_event_name !== "PreToolUse")
@@ -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
  }
@@ -0,0 +1,79 @@
1
+ /** "Never Twice" — turn a human correction of the agent into a first-class,
2
+ * enforced Constraint (DESIGN: Correction Capture → Enforced Constraint).
3
+ *
4
+ * Two pure, client-agnostic pieces, factored out of the MCP server and the
5
+ * agent hook so they are unit-testable without spinning either up:
6
+ * - looksLikeCorrection(): does a user prompt read like "no / that's wrong /
7
+ * never do X" — the cue to nudge the agent to persist it.
8
+ * - buildCorrectionConstraint(): mint the Constraint record (human-confirmed,
9
+ * scoped conservatively) that the pre-edit hook + CI guard then enforce.
10
+ */
11
+ import { constraintId } from "./ids.js";
12
+ import { toPosixTarget } from "./paths.js";
13
+ /** Correction cues. Deliberately conservative — anchored to imperative/rebuke
14
+ * phrasing, not bare "no", so ordinary conversational negation ("no idea",
15
+ * "no problem") doesn't train users to ignore the nudge (research risk #5). */
16
+ const CORRECTION_PATTERNS = [
17
+ // OPENS with a rebuke — but exclude benign "no problem / no idea / no test exists…".
18
+ // The exclusion list guards against stateful conversational negation ("no tests pass",
19
+ // "no way to fix this") firing the nudge and training users to ignore it.
20
+ /^\s*no\b(?!\s+(problem|worries|idea|rush|need|biggie|thanks|thank|prob|clue|luck|difference|harm|reason|point|test|tests|way|ways|chance|context|functions?|method|file|files|change|changes|diff|other|more))/i,
21
+ /^\s*(nope|stop)\b/i,
22
+ /\b(that'?s|that is|this is) (wrong|incorrect|not right|not what)\b/i,
23
+ /\b(never|do not ever|don'?t ever) (do|use|call|add|write|put|import|commit|touch)\b/i,
24
+ /\b(don'?t|do not) (do|use|call|add|write|put|commit) (that|this|it)\b/i,
25
+ /\b(you must|must always|you should always|make sure (to|you|that you))\b/i,
26
+ /\b(i (already )?told you|i said|as i said|like i said)\b/i,
27
+ /\b(undo|revert) (that|this|it|your)\b/i,
28
+ /\b(not like that|don'?t do (that|this)( again)?|stop doing (that|this))\b/i,
29
+ ];
30
+ export function looksLikeCorrection(prompt) {
31
+ if (!prompt || typeof prompt !== "string")
32
+ return false;
33
+ return CORRECTION_PATTERNS.some((re) => re.test(prompt));
34
+ }
35
+ /** One-line nudge appended to the UserPromptSubmit hook context when a prompt
36
+ * reads like a correction — surfaces the write tool so the rule gets ENFORCED,
37
+ * not merely remembered. Client-agnostic (no Claude-only wording). */
38
+ export const CORRECTION_NUDGE = "This looks like a correction. If it's a rule the agent should never break again, " +
39
+ "call hunch_record_correction({ rule, scope_hint_file, severity, applies_to_all }) so it " +
40
+ "becomes an enforced, scoped constraint (held at edit-time and in CI) — not a one-off the next session forgets. " +
41
+ "Use severity:\"blocking\" only when the human said never/must; set applies_to_all:true only if the rule is genuinely repo-wide.";
42
+ /**
43
+ * Build the Constraint a correction mints. Pure (caller passes `now`), so the
44
+ * scope/severity policy is testable in isolation. Key safety rule (research
45
+ * risk #2 — the scope footgun): a repo-wide ("**") constraint may only be
46
+ * BLOCKING when the caller explicitly set applies_to_all; otherwise a single
47
+ * mis-scoped correction would deny every edit under strict firmness, so we
48
+ * down-rank it to a warning.
49
+ */
50
+ export function buildCorrectionConstraint(input, now) {
51
+ const rule = input.rule.trim();
52
+ if (!rule)
53
+ throw new Error("rule must not be empty");
54
+ // A blank/"." scope hint would mint a meaningless or repo-wide constraint by
55
+ // accident, so fall back to "**" (which the severity guard below then keeps
56
+ // non-blocking unless applies_to_all was explicitly set).
57
+ const hinted = input.scope_hint_file ? toPosixTarget(input.scope_hint_file) : "";
58
+ const scope = input.applies_to_all || !hinted || hinted === "." ? ["**"] : [hinted];
59
+ const repoWide = scope.length === 1 && scope[0] === "**";
60
+ let severity = input.severity ?? "warning";
61
+ if (severity === "blocking" && repoWide && !input.applies_to_all)
62
+ severity = "warning";
63
+ return {
64
+ id: constraintId(rule),
65
+ type: input.type ?? "correctness",
66
+ statement: rule,
67
+ scope,
68
+ severity,
69
+ enforcement: "advisory_v1",
70
+ rationale: input.rationale ?? "Captured from a human correction of the agent (Never Twice).",
71
+ source_decision: input.source_decision ?? null,
72
+ violations: [],
73
+ status: "active",
74
+ valid_from: now,
75
+ valid_to: null,
76
+ provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: now },
77
+ };
78
+ }
79
+ //# sourceMappingURL=correction.js.map
package/dist/core/ids.js CHANGED
@@ -32,8 +32,10 @@ export function decisionId(seed) {
32
32
  export function bugId(seed) {
33
33
  return "bug_" + shortHash(seed);
34
34
  }
35
- /** Constraint id seeded by its statement. */
35
+ /** Constraint id seeded by its statement. Trim + lowercase so trivial
36
+ * whitespace/case variants of the same rule collapse to one id (idempotent
37
+ * re-capture), instead of minting a duplicate constraint. */
36
38
  export function constraintId(statement) {
37
- return "con_" + shortHash(statement.toLowerCase());
39
+ return "con_" + shortHash(statement.trim().toLowerCase());
38
40
  }
39
41
  //# sourceMappingURL=ids.js.map
@@ -13,8 +13,10 @@ import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
13
13
  import { HunchStore } from "../store/hunchStore.js";
14
14
  import { selectEmbedder } from "../store/embedder.js";
15
15
  import { decisionId } from "../core/ids.js";
16
- import { revParse, asOfDate } from "../extractors/git.js";
16
+ import { buildCorrectionConstraint } from "../core/correction.js";
17
+ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff } from "../extractors/git.js";
17
18
  import { formatContext } from "../core/format.js";
19
+ import { renderMarkdown, verdict } from "../core/checkreport.js";
18
20
  const ok = (text) => ({ content: [{ type: "text", text }] });
19
21
  const err = (text) => ({ content: [{ type: "text", text }], isError: true });
20
22
  // Read-side token budgets: every tool result is injected into a Claude Code
@@ -286,6 +288,70 @@ export function buildServer(root) {
286
288
  return err(`Failed to record decision: ${e.message}`);
287
289
  }
288
290
  });
291
+ // -- hunch_record_correction (write-back: "Never Twice") ------------------
292
+ server.registerTool("hunch_record_correction", {
293
+ title: "Capture a correction as an enforced constraint (Never Twice)",
294
+ description: "When a human corrects the agent ('no, do it this way' / 'never call X here'), persist that correction as a first-class, SCOPED Constraint with provenance — so the pre-edit hook and the CI Constraint Guard hold EVERY assistant to it from now on, instead of it being forgotten next session. Writes to the shared .hunch/ graph (client-agnostic). Set severity:'blocking' only when the human said never/must; set applies_to_all:true only when the rule is genuinely repo-wide (otherwise it is scoped to scope_hint_file).",
295
+ inputSchema: {
296
+ rule: z.string().describe("The invariant in the human's words, e.g. \"never call the pay-per-token API here\"."),
297
+ scope_hint_file: z.string().optional().describe("A file the correction was about; scopes the constraint to it (the conservative default)."),
298
+ severity: z.enum(["advisory", "warning", "blocking"]).optional().describe("Default 'warning'. Use 'blocking' only for a hard never/must rule."),
299
+ applies_to_all: z.boolean().optional().describe("True ONLY if the rule is genuinely repo-wide (scopes to **); required to make a repo-wide rule blocking."),
300
+ type: z.enum(["security", "performance", "correctness", "architecture", "compliance"]).optional(),
301
+ rationale: z.string().optional().describe("Why it must hold."),
302
+ source_decision: z.string().optional().describe("id of a decision this correction derives from."),
303
+ },
304
+ }, async (input) => {
305
+ try {
306
+ if (!input.rule || !input.rule.trim())
307
+ return err("rule is required — state the invariant in plain words.");
308
+ const rec = buildCorrectionConstraint(input, new Date().toISOString());
309
+ const existing = store.json.get("constraints", rec.id);
310
+ store.json.put("constraints", rec);
311
+ store.reindex();
312
+ const enforce = rec.severity === "blocking"
313
+ ? "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"
314
+ : "flags violating edits and PRs (advisory)";
315
+ return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}). It now ${enforce}.`);
316
+ }
317
+ catch (e) {
318
+ return err(`Failed to record correction: ${e.message}`);
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
+ });
289
355
  return server;
290
356
  }
291
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.12.2",
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.",