@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 +25 -0
- package/dist/cli/index.js +6 -46
- package/dist/core/checkreport.js +33 -1
- package/dist/mcp/server.js +36 -1
- package/dist/store/hunchStore.js +73 -0
- package/package.json +1 -1
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
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
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
|
|
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
|
-
|
|
610
|
-
|
|
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) {
|
package/dist/core/checkreport.js
CHANGED
|
@@ -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
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -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) {
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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.
|
|
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.",
|