@davesheffer/hunch 0.36.3 β 0.38.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 +18 -9
- package/dist/cli/index.js +98 -13
- package/dist/mcp/server.js +7 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# π§ Hunch β
|
|
1
|
+
# π§ Hunch β Architectural Conformance for AI code
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
4
4
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
@@ -6,19 +6,28 @@
|
|
|
6
6
|
[](https://nodejs.org)
|
|
7
7
|
[](https://modelcontextprotocol.io)
|
|
8
8
|
|
|
9
|
-
>
|
|
10
|
-
>
|
|
11
|
-
>
|
|
12
|
-
|
|
13
|
-
### β‘ 60-second start
|
|
9
|
+
> **A linter checks whether code matches a *pattern*. Hunch checks whether code still matches your *architecture*** β
|
|
10
|
+
> and blocks the AI change that breaks it, citing the decision and the past bug it would reopen.
|
|
11
|
+
> The semantic invariants pattern-SAST can't express (layering, must-reach, dependency direction),
|
|
12
|
+
> enforced deterministically over a **git-native** graph of *why* β across any MCP assistant.
|
|
14
13
|
|
|
15
14
|
```bash
|
|
16
15
|
npm i -g @davesheffer/hunch
|
|
17
|
-
cd your-repo && hunch init
|
|
18
|
-
|
|
16
|
+
cd your-repo && hunch init
|
|
17
|
+
|
|
18
|
+
# record an architectural invariant β the kind Semgrep/SonarQube structurally can't express
|
|
19
|
+
hunch conform --add "controllers never reach the DB directly β go through the service layer" \
|
|
20
|
+
--assert not-calls --subject listOrders --object dbQuery --why "the Mar-2025 N+1 meltdown"
|
|
21
|
+
|
|
22
|
+
hunch conform --strict # β
/β deterministic gate β wire into CI; runs on every AI change
|
|
19
23
|
```
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
> An AI "optimizes" the controller to query the DB directly. **Semgrep: green. SonarQube: green.**
|
|
26
|
+
> (it's a legitimate internal import β no bad pattern.) **Hunch: β BLOCKED** β *"listOrders now reaches
|
|
27
|
+
> dbQuery β VIOLATED Β· why: the Mar-2025 N+1 meltdown Β· prevents recurrence of bug_0317."* See
|
|
28
|
+
> [`demo/architectural-conformance.sh`](demo/architectural-conformance.sh).
|
|
29
|
+
|
|
30
|
+
<sub>Works with **Claude Code, Cursor, Copilot, Windsurf & Google Antigravity** from one shared, git-native graph.</sub>
|
|
22
31
|
|
|
23
32
|
### π **[Read the full documentation β hunch-pi.vercel.app/docs](https://hunch-pi.vercel.app/docs)**
|
|
24
33
|
|
package/dist/cli/index.js
CHANGED
|
@@ -738,34 +738,80 @@ program
|
|
|
738
738
|
console.log(`β captured ${dec} decision(s) + ${con} constraint(s) from inline comments${opts.private ? " [private overlay]" : ""}`);
|
|
739
739
|
store.close();
|
|
740
740
|
});
|
|
741
|
-
// ---- conform (
|
|
741
|
+
// ---- conform (Architectural Conformance: does the code still satisfy recorded intent) ----
|
|
742
742
|
program
|
|
743
743
|
.command("conform")
|
|
744
|
-
.description("
|
|
745
|
-
.option("--strict", "exit non-zero if any
|
|
744
|
+
.description("Architectural Conformance: prove the code still SATISFIES each recorded architectural invariant (deterministic, over the graph) β the semantic rules pattern-SAST can't express: layering, must-reach, dependency direction. Catches AI changes that pass a linter but break the architecture.")
|
|
745
|
+
.option("--strict", "exit non-zero if any invariant is violated (use as a CI gate)")
|
|
746
|
+
.option("--add <title>", "record an architectural invariant instead of checking, e.g. --add \"controllers never touch the DB directly\"")
|
|
747
|
+
.option("--assert <kind>", "calls | not-calls | imports | not-imports | exists (with --add)")
|
|
748
|
+
.option("--subject <sym>", "the symbol/file:name the invariant is about (with --add)")
|
|
749
|
+
.option("--object <sym>", "the symbol it must reach (calls/imports) or must NOT reach (not-calls/not-imports) (with --add)")
|
|
750
|
+
.option("--transitive", "evaluate reachability transitively, not just direct edges (with --add)")
|
|
751
|
+
.option("--why <text>", "why it holds β the rationale, surfaced in the block receipt (with --add)")
|
|
752
|
+
.option("--bug <id>", "the bug id this invariant prevents recurring β surfaced in the receipt (with --add)")
|
|
746
753
|
.action((opts) => {
|
|
747
|
-
const { store } = storeFor();
|
|
754
|
+
const { store, root } = storeFor();
|
|
755
|
+
if (opts.add) {
|
|
756
|
+
const ASSERTS = ["calls", "not-calls", "imports", "not-imports", "exists"];
|
|
757
|
+
if (!opts.assert || !ASSERTS.includes(opts.assert))
|
|
758
|
+
return fail(`--assert must be one of: ${ASSERTS.join(", ")}`);
|
|
759
|
+
if (!opts.subject)
|
|
760
|
+
return fail("--subject is required with --add");
|
|
761
|
+
if (opts.assert !== "exists" && !opts.object)
|
|
762
|
+
return fail(`--object is required for --assert ${opts.assert}`);
|
|
763
|
+
store.json.ensureDirs();
|
|
764
|
+
const now = new Date().toISOString();
|
|
765
|
+
const arrow = opts.assert.startsWith("not-") ? "β" : "β";
|
|
766
|
+
const d = store.json.put("decisions", {
|
|
767
|
+
id: decisionId(`conform:${opts.add}:${opts.subject}:${opts.object ?? ""}`),
|
|
768
|
+
title: opts.add,
|
|
769
|
+
status: "accepted",
|
|
770
|
+
context: opts.why ?? "",
|
|
771
|
+
decision: `Architectural invariant: ${opts.subject} ${opts.assert}${opts.object ? ` ${opts.object}` : ""}.`,
|
|
772
|
+
caused_by_bug: opts.bug ?? null,
|
|
773
|
+
conformance: [{ assert: opts.assert, subject: opts.subject, object: opts.assert === "exists" ? undefined : opts.object, transitive: !!opts.transitive }],
|
|
774
|
+
provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: now },
|
|
775
|
+
date: now,
|
|
776
|
+
valid_from: now,
|
|
777
|
+
});
|
|
778
|
+
store.reindex();
|
|
779
|
+
refreshExistingGrounding(root, store); // the invariant reaches every assistant's grounding
|
|
780
|
+
console.log(`β recorded architectural invariant ${d.id}: "${opts.add}"`);
|
|
781
|
+
console.log(` ${opts.subject} ${arrow} ${opts.object ?? ""}${opts.transitive ? " (transitive)" : ""} [${opts.assert}]`);
|
|
782
|
+
console.log(` enforce on every change: hunch conform --strict (wire into CI alongside hunch ci)`);
|
|
783
|
+
store.close();
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
748
786
|
store.reindex();
|
|
749
787
|
const results = checkConformance(store);
|
|
750
788
|
if (!results.length) {
|
|
751
|
-
console.log("No
|
|
752
|
-
console.log(dim(
|
|
789
|
+
console.log("No architectural invariants recorded yet.");
|
|
790
|
+
console.log(dim(' Record one: hunch conform --add "controllers never touch the DB directly" --assert not-calls --subject OrdersController --object dbQuery'));
|
|
753
791
|
store.close();
|
|
754
792
|
return;
|
|
755
793
|
}
|
|
756
794
|
const violations = results.filter((r) => !r.satisfied);
|
|
757
|
-
console.log(`
|
|
795
|
+
console.log(`Architectural conformance: ${results.length - violations.length}/${results.length} invariants satisfied\n`);
|
|
758
796
|
for (const r of results) {
|
|
759
797
|
console.log(` ${r.satisfied ? "β
" : "β"} ${r.decision} β "${r.title}"`);
|
|
760
798
|
console.log(` ${r.assert} ${r.subject}${r.object ? ` β ${r.object}` : ""}: ${r.detail}`);
|
|
799
|
+
if (!r.satisfied) {
|
|
800
|
+
// The receipt β WHY this invariant exists, which pattern-SAST can't tell you.
|
|
801
|
+
const dec = store.json.get("decisions", r.decision);
|
|
802
|
+
if (dec?.context)
|
|
803
|
+
console.log(` β³ why: ${dec.context}`);
|
|
804
|
+
if (dec?.caused_by_bug)
|
|
805
|
+
console.log(` β³ prevents recurrence of: ${dec.caused_by_bug}`);
|
|
806
|
+
}
|
|
761
807
|
}
|
|
762
808
|
if (violations.length) {
|
|
763
|
-
console.log(`\nβ ${violations.length}
|
|
809
|
+
console.log(`\nβ ${violations.length} architectural invariant(s) the code no longer satisfies β an AI change drifted from the recorded architecture.`);
|
|
764
810
|
if (opts.strict)
|
|
765
811
|
process.exitCode = 1;
|
|
766
812
|
}
|
|
767
813
|
else {
|
|
768
|
-
console.log(`\nβ
the code satisfies every recorded
|
|
814
|
+
console.log(`\nβ
the code satisfies every recorded architectural invariant.`);
|
|
769
815
|
}
|
|
770
816
|
store.close();
|
|
771
817
|
});
|
|
@@ -968,7 +1014,7 @@ program
|
|
|
968
1014
|
provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: new Date().toISOString() },
|
|
969
1015
|
});
|
|
970
1016
|
store.reindex();
|
|
971
|
-
|
|
1017
|
+
refreshExistingGrounding(root, store); // keep EVERY assistant's grounding current, not just CLAUDE.md
|
|
972
1018
|
console.log(`β recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})`);
|
|
973
1019
|
if (derived && c.forbids?.deps.length)
|
|
974
1020
|
console.log(` β³ matcher: forbids import of ${c.forbids.deps.join(", ")} (precise, immune to staleness)`);
|
|
@@ -1309,6 +1355,45 @@ program
|
|
|
1309
1355
|
const next = writeConfig(paths, { firmness: level }).firmness;
|
|
1310
1356
|
console.log(`β firmness set to ${next} (takes effect on the next edit β no Claude Code restart needed).`);
|
|
1311
1357
|
});
|
|
1358
|
+
// ---- status (enforcement readiness at a glance) ---------------------------
|
|
1359
|
+
program
|
|
1360
|
+
.command("status")
|
|
1361
|
+
.description("Enforcement readiness at a glance: what's enforcing, what's waiting to confirm, what went stale.")
|
|
1362
|
+
.action(() => {
|
|
1363
|
+
const { store, root } = storeFor();
|
|
1364
|
+
const firmness = readConfig(hunchPaths(root)).firmness;
|
|
1365
|
+
const vouchedSrc = (s) => !!s && (s.includes("human_confirmed") || s === "derived");
|
|
1366
|
+
const blocking = store.recs("constraints").filter((c) => c.status === "active" && c.severity === "blocking" && vouchedSrc(c.provenance?.source));
|
|
1367
|
+
const precise = blocking.filter((c) => !!effectiveForbids(c));
|
|
1368
|
+
const scopeOnly = blocking.filter((c) => !effectiveForbids(c));
|
|
1369
|
+
const drafts = store.json.loadAll("decisions").filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
|
|
1370
|
+
const { ready, scrutiny } = partitionReview(drafts, READY_MIN_GROUNDED);
|
|
1371
|
+
const stale = store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint");
|
|
1372
|
+
const fnote = {
|
|
1373
|
+
off: "not enforcing β `hunch firmness advisory` to start",
|
|
1374
|
+
advisory: "surfaces context to the agent; never blocks",
|
|
1375
|
+
firm: "surfaces + warns on a violating edit",
|
|
1376
|
+
strict: "edit-time DENY + CI guard β the teeth are on",
|
|
1377
|
+
};
|
|
1378
|
+
console.log(`\nHunch β enforcement status (${root.split("/").pop()})\n`);
|
|
1379
|
+
console.log(` firmness: ${firmness} β ${fnote[firmness] ?? ""}\n`);
|
|
1380
|
+
console.log(` β ARMED ${blocking.length} confirmed blocking invariant(s) β held against every assistant`);
|
|
1381
|
+
if (blocking.length) {
|
|
1382
|
+
console.log(` ${precise.length} precise (block the actual violation, immune to staleness)`);
|
|
1383
|
+
console.log(` ${scopeOnly.length} scope-only (relax to advisory once the file changes)${scopeOnly.length ? " β harden with --forbid-dep" : ""}`);
|
|
1384
|
+
}
|
|
1385
|
+
if (ready.length || scrutiny.length) {
|
|
1386
|
+
console.log(`\n β³ TO CONFIRM ${ready.length} ready Β· ${scrutiny.length} need scrutiny β hunch review${ready.length ? " --accept-verified" : ""}`);
|
|
1387
|
+
}
|
|
1388
|
+
if (stale.length) {
|
|
1389
|
+
console.log(`\n β» STALE ${stale.length} rule(s) whose guarded code moved since last verified β re-confirm to keep the teeth`);
|
|
1390
|
+
}
|
|
1391
|
+
if (firmness !== "strict" && precise.length) {
|
|
1392
|
+
console.log(`\n β‘ ${precise.length} precise rule(s) are armed but firmness is "${firmness}" β set \`hunch firmness strict\` to hard-block them.`);
|
|
1393
|
+
}
|
|
1394
|
+
console.log("");
|
|
1395
|
+
store.close();
|
|
1396
|
+
});
|
|
1312
1397
|
// ---- hook (Claude Code agent-hook handler) --------------------------------
|
|
1313
1398
|
program
|
|
1314
1399
|
.command("hook")
|
|
@@ -1446,7 +1531,7 @@ program
|
|
|
1446
1531
|
}
|
|
1447
1532
|
const { source, armed } = acceptDecision(store, d);
|
|
1448
1533
|
store.reindex();
|
|
1449
|
-
|
|
1534
|
+
refreshExistingGrounding(root, store); // confirming a rule must reach EVERY assistant's grounding
|
|
1450
1535
|
console.log(`β accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
|
|
1451
1536
|
}
|
|
1452
1537
|
else if (opts.reject) {
|
|
@@ -1467,7 +1552,7 @@ program
|
|
|
1467
1552
|
for (const it of ready)
|
|
1468
1553
|
armedTotal += acceptDecision(store, it.d).armed;
|
|
1469
1554
|
store.reindex();
|
|
1470
|
-
|
|
1555
|
+
refreshExistingGrounding(root, store); // batch-confirm must reach EVERY assistant's grounding
|
|
1471
1556
|
console.log(`β accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
|
|
1472
1557
|
for (const it of ready)
|
|
1473
1558
|
console.log(` ${it.d.id} grounded=${it.synth.grounded ?? "?"} ${it.d.title}`);
|
|
@@ -1557,7 +1642,7 @@ program
|
|
|
1557
1642
|
if (store.json.delete(c.kind, c.id))
|
|
1558
1643
|
removed++;
|
|
1559
1644
|
store.reindex();
|
|
1560
|
-
|
|
1645
|
+
refreshExistingGrounding(root, store); // removing records must reach EVERY assistant's grounding
|
|
1561
1646
|
console.log(`\nβ Removed ${removed} record(s).`);
|
|
1562
1647
|
}
|
|
1563
1648
|
else {
|
package/dist/mcp/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { selectEmbedder } from "../store/embedder.js";
|
|
|
15
15
|
import { decisionId } from "../core/ids.js";
|
|
16
16
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
17
17
|
import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
18
|
+
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
18
19
|
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, commitAndPushHunch, pullHunch } from "../extractors/git.js";
|
|
19
20
|
import { formatContext } from "../core/format.js";
|
|
20
21
|
import { compareCandidates } from "../core/compare.js";
|
|
@@ -370,6 +371,12 @@ export function buildServer(root) {
|
|
|
370
371
|
else
|
|
371
372
|
store.json.put("constraints", rec);
|
|
372
373
|
store.reindex();
|
|
374
|
+
// Propagate the new rule to EVERY assistant's ambient grounding (Cursor/Copilot/
|
|
375
|
+
// Windsurf/AGENTS.md/CLAUDE.md), so a correction captured in one assistant is held
|
|
376
|
+
// by all of them. Public only β a private rule must never render into committed
|
|
377
|
+
// grounding. Refresh-only: it never scaffolds a doc the project opted out of.
|
|
378
|
+
if (!input.private)
|
|
379
|
+
refreshExistingGrounding(root, store);
|
|
373
380
|
let flushed = "";
|
|
374
381
|
if (input.private && store.privateAutoCommit && store.privateDir) {
|
|
375
382
|
commitAndPushHunch(store.privateDir, `hunch: capture ${rec.id}`);
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture β the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express β grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",
|
|
7
7
|
"homepage": "https://hunch-pi.vercel.app",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|