@davesheffer/hunch 0.34.1 → 0.35.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/dist/cli/index.js CHANGED
@@ -13,9 +13,9 @@
13
13
  * mcp start the MCP server (Claude Code connects here)
14
14
  * doctor environment diagnostics
15
15
  */
16
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
17
17
  import { execFileSync, spawnSync } from "node:child_process";
18
- import { join, relative, resolve, isAbsolute } from "node:path";
18
+ import { join, relative, dirname, basename, resolve, isAbsolute } from "node:path";
19
19
  import { Command } from "commander";
20
20
  import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget } from "../core/paths.js";
21
21
  import { writeFileAtomic } from "../core/io.js";
@@ -47,6 +47,7 @@ import { formatContext } from "../core/format.js";
47
47
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
48
48
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
49
49
  import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
50
+ import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
50
51
  import { computeDrift } from "../core/drift.js";
51
52
  import { compareCandidates } from "../core/compare.js";
52
53
  import { checkConformance } from "../core/conformance.js";
@@ -514,14 +515,61 @@ program
514
515
  // ---- eval (retrieval quality; measures the graph-stream lift) --------------
515
516
  program
516
517
  .command("eval")
517
- .description("Measure retrieval quality (Recall@k, MRR) over a golden set, and A/B the dependency-graph stream.")
518
- .requiredOption("--file <path>", "golden set JSON: [{ query, expected: [refs], note? }]")
518
+ .description("Measure quality over a golden set: retrieval (Recall@k, MRR) or, with --guards, ENFORCEMENT (block/warn/pass precision & recall).")
519
+ .option("--file <path>", "golden set JSON — retrieval: [{ query, expected }]; guards: [{ name, files, expect }]")
520
+ .option("--guards", "score the GUARDS instead of retrieval: did the gate block the bad changes and pass the good ones?")
521
+ .option("--generate", "with --guards: scaffold a starter golden set from the live graph (and run it)")
519
522
  .option("--k <n>", "top-k cutoff", "10")
520
523
  .option("--semantic", "also blend the semantic stream (requires `hunch embed`; default is deterministic FTS + graph)")
521
524
  .option("--kind <kind>", "restrict scoring to one record kind (e.g. runbooks) — scoped retrieval")
522
525
  .action(async (opts) => {
523
526
  const { store } = storeFor();
524
527
  store.reindex(); // reflect any out-of-band JSON edits before scoring
528
+ // ── Guard eval: did the gate BLOCK the bad changes and PASS the good ones? Runs each
529
+ // case through the SAME buildCheckReport → verdict path the live guards use. ──
530
+ if (opts.guards) {
531
+ if (!opts.generate && !opts.file) {
532
+ store.close();
533
+ return fail("guard eval needs --file <golden.json> or --generate");
534
+ }
535
+ let gcases;
536
+ try {
537
+ gcases = opts.generate ? generateGuardCases(store) : loadGuardCases(readFileSync(opts.file, "utf8"));
538
+ }
539
+ catch (e) {
540
+ store.close();
541
+ return fail(`guard eval: ${e.message}`);
542
+ }
543
+ if (!gcases.length) {
544
+ store.close();
545
+ return fail("no guard cases — `--generate` needs vouched blocking constraints in the graph, or pass --file");
546
+ }
547
+ if (opts.generate && opts.file)
548
+ writeFileAtomic(opts.file, JSON.stringify(gcases, null, 2) + "\n");
549
+ const r = evalGuards(store, gcases);
550
+ const pct = (n, d) => (d ? `${((n / d) * 100).toFixed(0)}%` : "—");
551
+ console.log(`Guard eval over ${r.total} case(s) — does the gate catch bad changes and stay quiet on good ones?\n`);
552
+ console.log(` CAUGHT ${r.surfaced}/${r.shouldSurface} changes to guarded code surfaced (${pct(r.surfaced, r.shouldSurface)}) — nothing slips silently through`);
553
+ console.log(` └ of those ${r.hardBlocked} hard-block the merge, ${r.surfaced - r.hardBlocked} warn — stale/low-confidence rules warn; re-verify to harden`);
554
+ console.log(` FALSE-POSITIVE ${r.falsePositives}/${r.shouldPass} unrelated changes flagged (${pct(r.falsePositives, r.shouldPass)} — lower is safer to enable)`);
555
+ console.log(` ACCURACY ${(r.accuracy * 100).toFixed(0)}% exact verdict match\n`);
556
+ const wrong = r.perCase.filter((p) => !p.ok);
557
+ if (wrong.length) {
558
+ console.log(` ${wrong.length} mismatch(es) to review (relabel a generated case, or fix a guard):`);
559
+ for (const w of wrong.slice(0, 12))
560
+ console.log(` · ${w.name} — expected ${w.expect}, got ${w.got}`);
561
+ }
562
+ else {
563
+ console.log(` ✓ every case matched its expected verdict.`);
564
+ }
565
+ store.close();
566
+ return;
567
+ }
568
+ // ── Retrieval eval (default) ──
569
+ if (!opts.file) {
570
+ store.close();
571
+ return fail("retrieval eval needs --file <golden.json> (or use --guards)");
572
+ }
525
573
  let cases;
526
574
  try {
527
575
  cases = loadGoldenSet(readFileSync(opts.file, "utf8"));
@@ -667,7 +715,7 @@ program
667
715
  id, type: "correctness", statement: it.text, scope: [it.file],
668
716
  // Advisory by default — an inline rule never auto-blocks a build; raise severity
669
717
  // deliberately if you want enforcement. Keeps day-one zero false-positive rage.
670
- severity: "warning", enforcement: "advisory_v1",
718
+ severity: "warning", enforcement: "advisory_v1", match: null,
671
719
  rationale: `Captured from an inline hunch-rule comment (${it.file}:${it.line}).`,
672
720
  source_decision: null, violations: [], status: "active",
673
721
  valid_from: prev?.valid_from ?? now, valid_to: null,
@@ -880,6 +928,7 @@ program
880
928
  .option("--rationale <text>", "why it must hold", "")
881
929
  .option("--source-decision <id>", "decision id this derives from")
882
930
  .option("--enforcement <e>", "advisory_v1 | ci | manual", "advisory_v1")
931
+ .option("--match <regex>", "content matcher: block only when an ADDED line matches this regex (precise + immune to staleness, vs scope-touch)")
883
932
  .action((statement, opts) => {
884
933
  const SEV = ["advisory", "warning", "blocking"];
885
934
  if (!SEV.includes(opts.severity))
@@ -894,6 +943,7 @@ program
894
943
  scope,
895
944
  severity: opts.severity,
896
945
  enforcement: opts.enforcement,
946
+ match: opts.match ?? null,
897
947
  rationale: opts.rationale,
898
948
  source_decision: opts.sourceDecision ?? null,
899
949
  violations: [],
@@ -1278,16 +1328,18 @@ program
1278
1328
  // index is good enough for grounding.
1279
1329
  if (firmness === "strict") {
1280
1330
  store.reindex();
1281
- const deny = blockingInScope(store, target);
1331
+ // The lines this edit would ADD — so a content-matched invariant denies only
1332
+ // when the edit actually trips it (not on every edit in scope), and the Veto
1333
+ // Guard can test the proposed text. Covers Edit/Write/MultiEdit.
1334
+ const proposedLines = proposedEditLines(evt.tool_input);
1335
+ const deny = blockingInScope(store, target, proposedLines);
1282
1336
  if (deny) {
1283
1337
  emitDeny(deny.reason);
1284
1338
  return;
1285
1339
  }
1286
1340
  // Veto Guard (live): the proposed edit text re-introduces an approach an
1287
- // in-force decision REJECTED. Covers Edit (new_string), Write (content), and
1288
- // MultiEdit (edits[].new_string). The agent self-corrects before staging;
1341
+ // in-force decision REJECTED. The agent self-corrects before staging;
1289
1342
  // only human-confirmed tripwires deny.
1290
- const proposedLines = proposedEditLines(evt.tool_input);
1291
1343
  const vetoDeny = proposedLines.length ? vetoInScope(store, target, proposedLines) : null;
1292
1344
  if (vetoDeny) {
1293
1345
  emitDeny(vetoDeny.reason);
@@ -1650,8 +1702,27 @@ function readStdin() {
1650
1702
  }
1651
1703
  /** Absolute edit path → repo-relative, forward-slash (constraint scopes are
1652
1704
  * forward-slash globs even on Windows). */
1705
+ /** realpath a path even if it doesn't exist yet (a new file an agent is about to
1706
+ * Write): resolve the longest existing ancestor, then re-append the missing tail.
1707
+ * Idempotent on already-resolved paths. */
1708
+ function realpathNorm(p) {
1709
+ try {
1710
+ return realpathSync.native(p);
1711
+ }
1712
+ catch {
1713
+ const parent = dirname(p);
1714
+ if (parent === p)
1715
+ return p; // hit the root; nothing more to resolve
1716
+ return join(realpathNorm(parent), basename(p));
1717
+ }
1718
+ }
1719
+ /** Repo-relative POSIX path. BOTH ends are realpath-normalized first: on macOS
1720
+ * `process.cwd()` (hence findRoot) resolves /var→/private/var, but a hook event's
1721
+ * file_path arrives UN-resolved — so a naive relative() yields a bogus "../" path
1722
+ * under any symlinked root (/var, /tmp, symlinked $HOME) and the caller treats the
1723
+ * file as outside the repo, silently dropping all context (dec_e0a36efbf5). */
1653
1724
  function toRepoRel(root, abs) {
1654
- return relative(root, abs).split("\\").join("/");
1725
+ return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
1655
1726
  }
1656
1727
  function emitContext(event, text) {
1657
1728
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }));
@@ -0,0 +1,28 @@
1
+ /** Optional CONTENT matcher for a constraint: a regex tested against the lines a
2
+ * diff/edit ADDS. When a constraint carries one, the gate decides a violation by
3
+ * CONTENT (the rule was actually broken) instead of by bare SCOPE-touch.
4
+ *
5
+ * Why this matters: scope-touch enforcement is so blunt that strict had to fail
6
+ * OPEN once a guarded file changed (the "staleness" gate) or it would block every
7
+ * edit in scope — which silently retracts the teeth over a file's normal life
8
+ * (dec_e0a36efbf5). A content match is verifiable PER COMMIT, so it needs no
9
+ * staleness proxy: a vouched, content-matched invariant keeps blocking the actual
10
+ * violation across the whole life of the file, and stays quiet on edits that don't
11
+ * break it. Bad user/LLM regex is compiled defensively and is simply inert. */
12
+ export function constraintMatcher(pattern) {
13
+ if (!pattern)
14
+ return null;
15
+ try {
16
+ return new RegExp(pattern);
17
+ }
18
+ catch {
19
+ return null; // malformed pattern → inert, never throws
20
+ }
21
+ }
22
+ /** True iff any ADDED line trips the constraint's content matcher. */
23
+ export function contentViolates(re, addedLines) {
24
+ if (!re)
25
+ return false;
26
+ return addedLines.some((l) => re.test(l));
27
+ }
28
+ //# sourceMappingURL=constraintmatch.js.map
@@ -67,6 +67,7 @@ export function buildCorrectionConstraint(input, now) {
67
67
  scope,
68
68
  severity,
69
69
  enforcement: "advisory_v1",
70
+ match: null,
70
71
  rationale: input.rationale ?? "Captured from a human correction of the agent (Never Twice).",
71
72
  source_decision: input.source_decision ?? null,
72
73
  violations: [],
@@ -1,20 +1,33 @@
1
+ import { constraintMatcher, contentViolates } from "./constraintmatch.js";
1
2
  /** Return a BlockingHit if editing `file` (repo-relative) hits a blocking
2
- * invariant directly or through its blast radius, else null. */
3
- export function blockingInScope(store, file) {
3
+ * invariant directly or through its blast radius, else null. `proposedAddedLines`
4
+ * are the lines the edit would ADD: a CONTENT-MATCHED invariant (one carrying a
5
+ * `match` regex) denies ONLY when those lines actually trip it — so it stays quiet
6
+ * on edits that don't break the rule, instead of blocking every edit in scope
7
+ * (dec_e0a36efbf5). Scope-only invariants keep the blunt scope-touch behavior. */
8
+ export function blockingInScope(store, file, proposedAddedLines = []) {
4
9
  for (const c of store.checkConstraints(file)) {
5
- if (c.severity === "blocking") {
6
- return {
7
- reason: `Hunch: editing ${file} would touch a BLOCKING invariant — "${c.statement}" (${c.id}). Do not proceed unless this change is meant to modify that invariant; otherwise preserve it.`,
8
- };
9
- }
10
+ if (c.severity !== "blocking")
11
+ continue;
12
+ const re = constraintMatcher(c.match);
13
+ if (re && !contentViolates(re, proposedAddedLines))
14
+ continue; // content-matched & not tripped → allow
15
+ return {
16
+ reason: `Hunch: editing ${file} would touch a BLOCKING invariant — "${c.statement}" (${c.id}). Do not proceed unless this change is meant to modify that invariant; otherwise preserve it.`,
17
+ };
10
18
  }
11
19
  for (const b of store.blastRadiusFiles(file)) {
12
20
  for (const c of store.checkConstraints(b.file)) {
13
- if (c.severity === "blocking") {
14
- return {
15
- reason: `Hunch: ${file} is in the blast radius of a BLOCKING invariant — "${c.statement}" (${c.id}; via ${b.file}, ${b.via} depth ${b.depth}). Verify the invariant still holds before editing.`,
16
- };
17
- }
21
+ if (c.severity !== "blocking")
22
+ continue;
23
+ // A content matcher tests the EDITED file's own added lines; it has nothing to
24
+ // assert about a transitive dependency, so content-matched invariants don't fire
25
+ // via blast radius — only scope-only invariants keep the blast-radius warning.
26
+ if (constraintMatcher(c.match))
27
+ continue;
28
+ return {
29
+ reason: `Hunch: ${file} is in the blast radius of a BLOCKING invariant — "${c.statement}" (${c.id}; via ${b.file}, ${b.via} depth ${b.depth}). Verify the invariant still holds before editing.`,
30
+ };
18
31
  }
19
32
  }
20
33
  return null;
@@ -165,6 +165,11 @@ export const ConstraintSchema = z.object({
165
165
  scope: z.array(z.string()).default([]).describe("glob(s) it applies to"),
166
166
  severity: z.enum(["advisory", "warning", "blocking"]).default("warning"),
167
167
  enforcement: z.enum(["advisory_v1", "ci", "manual"]).default("advisory_v1"),
168
+ // Optional CONTENT matcher (regex): the gate blocks when an ADDED line matches it,
169
+ // instead of on bare scope-touch. A content-verifiable invariant is decided per
170
+ // commit, so it is immune to file-change "staleness" and keeps its teeth across the
171
+ // file's whole life — and stays quiet on edits that don't break it (dec_e0a36efbf5).
172
+ match: z.string().nullable().default(null),
168
173
  rationale: z.string().default(""),
169
174
  source_decision: z.string().nullable().default(null),
170
175
  violations: z.array(z.string()).default([]),
@@ -0,0 +1,70 @@
1
+ import { verdict } from "../core/checkreport.js";
2
+ import { pathMatchesGlob } from "../core/glob.js";
3
+ const surfaced = (v) => v === "block" || v === "warn";
4
+ const matches = (expect, got) => (expect === "catch" ? surfaced(got) : got === expect);
5
+ /** Parse + validate a hand-authored golden set. */
6
+ export function loadGuardCases(json) {
7
+ const raw = JSON.parse(json);
8
+ if (!Array.isArray(raw))
9
+ throw new Error("expected a JSON array of guard cases");
10
+ return raw.map((c, i) => {
11
+ const x = c;
12
+ if (!x || typeof x.name !== "string" || !Array.isArray(x.files) || !["block", "warn", "pass", "catch"].includes(x.expect)) {
13
+ throw new Error(`case ${i}: need { name, files: [..], expect: "block"|"warn"|"pass"|"catch", diff? }`);
14
+ }
15
+ return { name: x.name, files: x.files, diff: typeof x.diff === "string" ? x.diff : "", expect: x.expect };
16
+ });
17
+ }
18
+ /** Score every case through the real guard pipeline. */
19
+ export function evalGuards(store, cases) {
20
+ const now = new Date().toISOString();
21
+ const perCase = cases.map((c) => {
22
+ // Treat each changed file as just-edited (so staleness is decided by the record's own
23
+ // last_verified, exactly as in a live `hunch check --strict`), via the SHARED report builder.
24
+ const got = verdict(store.buildCheckReport(c.files, c.diff ?? "", { strict: true, lastChange: () => now }));
25
+ return { name: c.name, expect: c.expect, got, ok: matches(c.expect, got) };
26
+ });
27
+ const shouldSurface = cases.filter((c) => c.expect !== "pass").length;
28
+ const shouldPass = cases.length - shouldSurface;
29
+ return {
30
+ total: cases.length,
31
+ shouldSurface,
32
+ surfaced: perCase.filter((p) => p.expect !== "pass" && surfaced(p.got)).length,
33
+ hardBlocked: perCase.filter((p) => p.expect !== "pass" && p.got === "block").length,
34
+ shouldPass,
35
+ falsePositives: perCase.filter((p) => p.expect === "pass" && surfaced(p.got)).length,
36
+ accuracy: perCase.length ? perCase.filter((p) => p.ok).length / perCase.length : 0,
37
+ perCase,
38
+ };
39
+ }
40
+ /** Scaffold a STARTER set from the live graph: every active, vouched blocking constraint →
41
+ * a CATCH case (a file in its scope — a change there must not slip silently past the gate);
42
+ * a few unrelated paths → PASS cases (the precision side — the gate must NOT over-flag). Both
43
+ * sides are true ground truth. Hand-add regressions / near-misses for fuller coverage. */
44
+ export function generateGuardCases(store) {
45
+ const cases = [];
46
+ const blocking = store
47
+ .recs("constraints")
48
+ .filter((c) => c.status === "active" && c.severity === "blocking" && c.scope.length && isVouched(c.provenance?.source));
49
+ for (const c of blocking) {
50
+ cases.push({ name: `CATCH · ${c.statement.slice(0, 56)}`, files: [pathForGlob(c.scope[0])], expect: "catch" });
51
+ }
52
+ for (const p of ["docs/__eval__notes.md", "scripts/__eval__.txt", ".github/__eval__.yml"]) {
53
+ if (!blocking.some((c) => c.scope.some((g) => pathMatchesGlob(p, g)))) {
54
+ cases.push({ name: `PASS · unrelated ${p}`, files: [p], expect: "pass" });
55
+ }
56
+ }
57
+ return cases;
58
+ }
59
+ const isVouched = (source) => !!source && (source.includes("human_confirmed") || source === "derived");
60
+ /** A concrete path that matches a scope glob, for a synthetic case. Exact-file scopes pass
61
+ * through; wildcard scopes get a representative file inside them. */
62
+ function pathForGlob(glob) {
63
+ if (!/[*]/.test(glob))
64
+ return glob;
65
+ let p = glob.replace(/\*\*/g, "x").replace(/\*/g, "x").replace(/\/+/g, "/").replace(/\/$/, "");
66
+ if (!/\.[a-z0-9]+$/i.test(p))
67
+ p += "/__eval__.ts";
68
+ return p.replace(/^\.?\//, "");
69
+ }
70
+ //# sourceMappingURL=guards.js.map
@@ -22,6 +22,7 @@ import { gitCommonDir } from "../extractors/git.js";
22
22
  import { pathMatchesGlob } from "../core/glob.js";
23
23
  import { edgeId } from "../core/ids.js";
24
24
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
25
+ import { constraintMatcher, contentViolates } from "../core/constraintmatch.js";
25
26
  import { analyzeDiff } from "../extractors/diff.js";
26
27
  export class HunchStore {
27
28
  paths;
@@ -652,16 +653,36 @@ export class HunchStore {
652
653
  movedFrom: [...an.filesRenamed.map((r) => r.from), ...an.filesDeleted],
653
654
  removedNames: new Set(an.removedSymbols.map((s) => s.name)),
654
655
  });
655
- const directReport = [...direct.values()].map(({ c, files: fs }) => {
656
+ const directReport = [];
657
+ for (const { c, files: fs } of direct.values()) {
658
+ const re = constraintMatcher(c.match);
659
+ if (re) {
660
+ // CONTENT-MATCHED: decide by whether an ADDED line in the matched files actually
661
+ // breaks the rule — not by bare scope-touch. A commit that touches the scope but
662
+ // doesn't trip the matcher COMPLIES → drop it (no noise). A real hit blocks WITHOUT
663
+ // the staleness gate: content is verified per commit, so file churn can't retract
664
+ // the teeth (dec_e0a36efbf5). Empty diff ⇒ can't prove a violation ⇒ treat as clean.
665
+ const added = fs.flatMap((f) => an.addedLinesByFile.get(f) ?? []);
666
+ if (!contentViolates(re, added))
667
+ continue;
668
+ const strictBlocks = isStrictBlocker(c, false);
669
+ directReport.push({
670
+ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
671
+ files: fs, strictBlocks,
672
+ downgrade: c.severity === "blocking" && !strictBlocks ? "low-confidence" : undefined,
673
+ why: this.causalChain(c.id),
674
+ });
675
+ continue;
676
+ }
656
677
  const stale = staleIds.has(c.id);
657
678
  const strictBlocks = isStrictBlocker(c, stale);
658
- return {
679
+ directReport.push({
659
680
  id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
660
681
  files: fs, strictBlocks,
661
682
  downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
662
683
  why: this.causalChain(c.id),
663
- };
664
- });
684
+ });
685
+ }
665
686
  return {
666
687
  fileCount: files.length,
667
688
  strict: opts.strict,
@@ -280,6 +280,7 @@ function promoteConstraint(store, bug) {
280
280
  scope,
281
281
  severity: bug.severity === "critical" ? "blocking" : "warning",
282
282
  enforcement: "advisory_v1",
283
+ match: null,
283
284
  rationale: `Derived from ${bug.id}: ${bug.root_cause || bug.symptom}`,
284
285
  source_decision: null,
285
286
  violations: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.34.1",
3
+ "version": "0.35.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 graph of the decisions, bugs, and rules behind your code, served to any MCP coding assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",