@davesheffer/hunch 0.40.0 → 1.1.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 +3 -3
- package/dist/cli/index.js +127 -13
- package/dist/core/checkreport.js +38 -0
- package/dist/core/docanchors.js +41 -0
- package/dist/core/drift.js +32 -8
- package/dist/core/paths.js +8 -6
- package/dist/mcp/server.js +57 -1
- package/dist/store/db.js +43 -5
- package/dist/store/hunchStore.js +78 -8
- package/package.json +3 -5
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
5
5
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
6
6
|
[](LICENSE)
|
|
7
|
-
[](https://nodejs.org)
|
|
8
8
|
[](https://modelcontextprotocol.io)
|
|
9
9
|
|
|
10
10
|
> **A linter checks whether code matches a *pattern*. Hunch checks whether code still matches your *architecture*** —
|
|
@@ -140,7 +140,7 @@ unchanged.
|
|
|
140
140
|
## Getting started
|
|
141
141
|
|
|
142
142
|
```bash
|
|
143
|
-
npm install -g @davesheffer/hunch # Node ≥
|
|
143
|
+
npm install -g @davesheffer/hunch # Node ≥ 22.13; puts `hunch` on your PATH
|
|
144
144
|
cd your-repo
|
|
145
145
|
hunch init # scaffold .hunch/, index, install hooks, wire up assistants
|
|
146
146
|
hunch backfill --since 90d # cold start: seed decisions from recent git history
|
|
@@ -320,5 +320,5 @@ memory. → [the docs](https://hunch-pi.vercel.app/docs) for the conceptual mode
|
|
|
320
320
|
|
|
321
321
|
## Develop
|
|
322
322
|
|
|
323
|
-
Hunch is open source — pure TypeScript ESM, Node ≥
|
|
323
|
+
Hunch is open source — pure TypeScript ESM, Node ≥ 22.13, licensed **Apache-2.0**. Contributions
|
|
324
324
|
welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the [repo](https://github.com/davesheffer/hunch).
|
package/dist/cli/index.js
CHANGED
|
@@ -33,7 +33,7 @@ import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrati
|
|
|
33
33
|
import { runbookId, decisionId } from "../core/ids.js";
|
|
34
34
|
import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
35
35
|
import { extractInlineIntent } from "../extractors/comments.js";
|
|
36
|
-
import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
|
|
36
|
+
import { renderText, renderMarkdown, renderImpact, reportFailsStrict } from "../core/checkreport.js";
|
|
37
37
|
import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
38
38
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
39
39
|
import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
|
|
@@ -52,6 +52,7 @@ import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
|
52
52
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
53
53
|
import { computeDrift } from "../core/drift.js";
|
|
54
54
|
import { topicCollisions, renderGrounding } from "../core/topics.js";
|
|
55
|
+
import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
|
|
55
56
|
import { compareCandidates } from "../core/compare.js";
|
|
56
57
|
import { checkConformance } from "../core/conformance.js";
|
|
57
58
|
import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
|
|
@@ -1572,12 +1573,22 @@ program
|
|
|
1572
1573
|
}
|
|
1573
1574
|
}
|
|
1574
1575
|
// advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
|
|
1576
|
+
// Decision-grounding for PROSE (doc≠graph): a markdown target that declares
|
|
1577
|
+
// <!-- hunch:topic … --> anchors gets each topic's CURRENT decision — the
|
|
1578
|
+
// graph outranks the prose being edited, and a stale pin is called out inline.
|
|
1579
|
+
let docGround = "";
|
|
1580
|
+
if (/\.(md|mdx)$/i.test(target)) {
|
|
1581
|
+
try {
|
|
1582
|
+
docGround = renderDocGrounding(parseDocAnchors(readFileSync(abs, "utf8")), store.recs("decisions"));
|
|
1583
|
+
}
|
|
1584
|
+
catch { /* unreadable / not yet created — no doc grounding */ }
|
|
1585
|
+
}
|
|
1575
1586
|
const ctx = store.assembleContext(target);
|
|
1576
1587
|
// Regression Guard (edit-time grounding): what an in-force decision retired
|
|
1577
1588
|
// from this file. No diff exists yet, so this is context — "don't re-add X" —
|
|
1578
1589
|
// not a block; the commit-time `hunch check` does the actual gating.
|
|
1579
1590
|
const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
|
|
1580
|
-
const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || retired.length;
|
|
1591
|
+
const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || retired.length || docGround;
|
|
1581
1592
|
if (!hasContent)
|
|
1582
1593
|
return; // no noise on files Hunch hasn't learned yet
|
|
1583
1594
|
let text = formatContext(ctx).trim();
|
|
@@ -1594,6 +1605,8 @@ program
|
|
|
1594
1605
|
const grounding = renderGrounding(ctx.decisions);
|
|
1595
1606
|
if (grounding)
|
|
1596
1607
|
text += `\n\n${grounding}`;
|
|
1608
|
+
if (docGround)
|
|
1609
|
+
text += `\n\n${docGround}`;
|
|
1597
1610
|
emitContext("PreToolUse", text);
|
|
1598
1611
|
}
|
|
1599
1612
|
catch {
|
|
@@ -1768,7 +1781,7 @@ program
|
|
|
1768
1781
|
// ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
|
|
1769
1782
|
program
|
|
1770
1783
|
.command("drift")
|
|
1771
|
-
.description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs,
|
|
1784
|
+
.description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, doc≠graph anchor-stale (a file still anchored to a superseded decision), and markdown sections whose <!-- hunch:topic … dec_id --> pin points at a superseded or missing decision (AGENTS.md/CLAUDE.md as a drift surface). Exits non-zero on any anchor-stale drift or topic collision — the doc≠graph gate.")
|
|
1772
1785
|
.action(() => {
|
|
1773
1786
|
const { store, root } = storeFor();
|
|
1774
1787
|
try {
|
|
@@ -1782,7 +1795,7 @@ program
|
|
|
1782
1795
|
console.log(`· [${f.kind}] ${f.id} — ${f.detail}`);
|
|
1783
1796
|
for (const [topic, decs] of collisions)
|
|
1784
1797
|
console.log(`· [topic-collision] "${topic}" has ${decs.length} live decisions: ${decs.map((d) => d.id).join(", ")} — run \`hunch reconcile-topics\``);
|
|
1785
|
-
const anchor = findings.filter((f) => f.kind === "anchor-stale").length;
|
|
1798
|
+
const anchor = findings.filter((f) => f.kind === "anchor-stale" || f.kind === "doc-anchor-stale").length;
|
|
1786
1799
|
console.log(`\n${findings.length} finding(s)${anchor ? `, ${anchor} doc≠graph (anchor-stale)` : ""}${collisions.size ? `, ${collisions.size} topic-collision(s)` : ""}.`);
|
|
1787
1800
|
if (anchor || collisions.size)
|
|
1788
1801
|
process.exitCode = 1;
|
|
@@ -1791,23 +1804,124 @@ program
|
|
|
1791
1804
|
store.close();
|
|
1792
1805
|
}
|
|
1793
1806
|
});
|
|
1807
|
+
// ---- path (shortest dependency chain) --------------------------------------
|
|
1808
|
+
program
|
|
1809
|
+
.command("path")
|
|
1810
|
+
.description("Shortest dependency path between two symbols/files/components — 'how does A reach B?'. Walks call/import/dependency/contains edges in either direction. Read-only.")
|
|
1811
|
+
.argument("<from>", "symbol id/name or file path")
|
|
1812
|
+
.argument("<to>", "symbol id/name or file path")
|
|
1813
|
+
.option("--max-depth <n>", "maximum hops to search", "8")
|
|
1814
|
+
.action((from, to, opts) => {
|
|
1815
|
+
const { store } = storeFor();
|
|
1816
|
+
try {
|
|
1817
|
+
store.reindex(); // reflect out-of-band JSON edits before walking the graph
|
|
1818
|
+
const A = store.resolveNodeIds(from);
|
|
1819
|
+
const B = store.resolveNodeIds(to);
|
|
1820
|
+
if (!A.length)
|
|
1821
|
+
return fail(`"${from}" resolves to no indexed symbol/component (run \`hunch index\`?).`);
|
|
1822
|
+
if (!B.length)
|
|
1823
|
+
return fail(`"${to}" resolves to no indexed symbol/component.`);
|
|
1824
|
+
let best = null;
|
|
1825
|
+
for (const a of A.slice(0, 4)) {
|
|
1826
|
+
for (const b of B.slice(0, 4)) {
|
|
1827
|
+
const p = store.shortestPath(a, b, Number(opts.maxDepth) || 8);
|
|
1828
|
+
if (p && (!best || p.length < best.length))
|
|
1829
|
+
best = p;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
if (!best) {
|
|
1833
|
+
console.log(`No path between "${from}" and "${to}" within ${opts.maxDepth} hop(s).`);
|
|
1834
|
+
process.exitCode = 1;
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
const last = best.length - 1;
|
|
1838
|
+
console.log(`${last} hop(s):`);
|
|
1839
|
+
best.forEach((n, i) => console.log(` ${i === 0 ? "┌" : i === last ? "└" : "├"} ${n.via}${n.via === n.id ? "" : ` (${n.id})`}`));
|
|
1840
|
+
}
|
|
1841
|
+
finally {
|
|
1842
|
+
store.close();
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1845
|
+
// ---- impact (PR impact — read-only, advisory) ------------------------------
|
|
1846
|
+
program
|
|
1847
|
+
.command("impact")
|
|
1848
|
+
.description("PR impact: the dependency + memory surface of a change — dependent files reached, invariants direct/near, and the decisions concerned. Read-only, advisory (gating is `hunch check`). Omit base and --commit to inspect staged changes.")
|
|
1849
|
+
.argument("[base]", "diff against this base ref (e.g. origin/main) for a branch/PR")
|
|
1850
|
+
.option("--commit <sha>", "impact of a single commit")
|
|
1851
|
+
.action((base, opts) => {
|
|
1852
|
+
const { store, root } = storeFor();
|
|
1853
|
+
try {
|
|
1854
|
+
if (base && opts.commit)
|
|
1855
|
+
return fail("Pass at most one of [base] / --commit.");
|
|
1856
|
+
if (base && !revExists(base, root))
|
|
1857
|
+
return fail(`base ref "${base}" does not resolve.`);
|
|
1858
|
+
if (opts.commit && !revExists(opts.commit, root))
|
|
1859
|
+
return fail(`commit "${opts.commit}" does not resolve.`);
|
|
1860
|
+
store.reindex(); // reflect out-of-band JSON edits before reading the graph
|
|
1861
|
+
const files = opts.commit ? commitFiles(opts.commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
|
|
1862
|
+
const scope = opts.commit ? `commit ${opts.commit}` : base ? `${base}..HEAD` : "staged changes";
|
|
1863
|
+
if (!files.length) {
|
|
1864
|
+
console.log(`No changed files in ${scope}.`);
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
|
|
1868
|
+
console.log(renderImpact(store.prImpact(files, diff), scope));
|
|
1869
|
+
}
|
|
1870
|
+
finally {
|
|
1871
|
+
store.close();
|
|
1872
|
+
}
|
|
1873
|
+
});
|
|
1794
1874
|
// ---- heal (decision-grounded drift reconciliation front door) -------------
|
|
1795
1875
|
program
|
|
1796
1876
|
.command("heal")
|
|
1797
|
-
.description("
|
|
1877
|
+
.description("Drift reconciliation front door: every `hunch drift` finding with its next action — doc≠graph anchor-stale (reconcile toward the current decision), dead refs, dangling supersedes, stale 'proposed' docs. Read-only — proposes, never rewrites. Escalate to /capture only if the DECISION (not the doc) is stale.")
|
|
1798
1878
|
.action(() => {
|
|
1799
1879
|
const { store, root } = storeFor();
|
|
1800
1880
|
try {
|
|
1801
|
-
const
|
|
1802
|
-
if (!
|
|
1803
|
-
console.log("✓ No
|
|
1881
|
+
const findings = computeDrift(store, root).findings;
|
|
1882
|
+
if (!findings.length) {
|
|
1883
|
+
console.log("✓ No drift to heal — memory matches the code and docs.");
|
|
1804
1884
|
return;
|
|
1805
1885
|
}
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1886
|
+
// Every drift kind heals here — `hunch drift` reporting N findings while heal
|
|
1887
|
+
// says "nothing to heal" reads as a broken loop (bug_drift_heal_asymmetry).
|
|
1888
|
+
const kind = (k) => findings.filter((f) => f.kind === k);
|
|
1889
|
+
const anchor = kind("anchor-stale");
|
|
1890
|
+
if (anchor.length) {
|
|
1891
|
+
console.log(`${anchor.length} anchored section(s) drifted from the graph (doc≠graph):\n`);
|
|
1892
|
+
for (const f of anchor)
|
|
1893
|
+
console.log(`· ${f.detail}`);
|
|
1894
|
+
console.log(`\nHeal A (doc stale): edit each file to match its CURRENT decision — a prose fix.`);
|
|
1895
|
+
console.log(`Heal B (decision stale): only if the DECISION is wrong now, run /capture (hunch_capture_decision) to supersede it, then re-derive the doc.\n`);
|
|
1896
|
+
}
|
|
1897
|
+
const docAnchor = [...kind("doc-anchor-stale"), ...kind("doc-anchor-dangling")];
|
|
1898
|
+
if (docAnchor.length) {
|
|
1899
|
+
console.log(`${docAnchor.length} markdown section(s) drifted from the graph (prose≠graph):\n`);
|
|
1900
|
+
for (const f of docAnchor)
|
|
1901
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
1902
|
+
console.log(`\nHeal: edit the prose to match the CURRENT decision, then update the pin in the <!-- hunch:topic … --> marker to its id. If the DECISION is what's wrong, run /capture to supersede it first.\n`);
|
|
1903
|
+
}
|
|
1904
|
+
const dead = kind("dead-ref");
|
|
1905
|
+
if (dead.length) {
|
|
1906
|
+
console.log(`${dead.length} dead reference(s) — an in-force decision points at a file that no longer exists:\n`);
|
|
1907
|
+
for (const f of dead)
|
|
1908
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
1909
|
+
console.log(`\nHeal: update the decision's related_files to the file's new location — or supersede the decision if it no longer applies.\n`);
|
|
1910
|
+
}
|
|
1911
|
+
const dangling = kind("supersede");
|
|
1912
|
+
if (dangling.length) {
|
|
1913
|
+
console.log(`${dangling.length} dangling supersede(s) — the old decision was never properly closed:\n`);
|
|
1914
|
+
for (const f of dangling)
|
|
1915
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
1916
|
+
console.log(`\nHeal: run \`hunch supersede <old> --by <new>\` to close the window and link them.\n`);
|
|
1917
|
+
}
|
|
1918
|
+
const docStale = kind("doc-stale");
|
|
1919
|
+
if (docStale.length) {
|
|
1920
|
+
console.log(`${docStale.length} stale doc(s) — still marked proposed/not-implemented but the code shipped:\n`);
|
|
1921
|
+
for (const f of docStale)
|
|
1922
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
1923
|
+
console.log(`\nHeal: update the doc's status marker to match reality.\n`);
|
|
1924
|
+
}
|
|
1811
1925
|
console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
|
|
1812
1926
|
}
|
|
1813
1927
|
finally {
|
package/dist/core/checkreport.js
CHANGED
|
@@ -6,6 +6,44 @@
|
|
|
6
6
|
export function reportIsClean(r) {
|
|
7
7
|
return r.direct.length === 0 && r.near.length === 0 && r.regressions.length === 0 && r.vetoes.length === 0 && r.redundant.length === 0;
|
|
8
8
|
}
|
|
9
|
+
/** Terminal/markdown-lite rendering of an ImpactReport (hunch impact / hunch_pr_impact). */
|
|
10
|
+
export function renderImpact(im, scope) {
|
|
11
|
+
const out = [];
|
|
12
|
+
out.push(`Impact of ${scope} — ${im.files.length} changed file(s) → ${im.blast.length} dependent file(s):`);
|
|
13
|
+
if (im.blast.length) {
|
|
14
|
+
const cap = 20;
|
|
15
|
+
for (const b of im.blast.slice(0, cap))
|
|
16
|
+
out.push(` • [depth ${b.depth}] ${b.file} (via ${b.via})`);
|
|
17
|
+
if (im.blast.length > cap)
|
|
18
|
+
out.push(` …(+${im.blast.length - cap} more, closest first)`);
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
out.push(" (nothing in the graph depends on these files)");
|
|
22
|
+
}
|
|
23
|
+
const r = im.report;
|
|
24
|
+
if (r.direct.length) {
|
|
25
|
+
out.push(`\nInvariants DIRECTLY in scope (${r.direct.length}):`);
|
|
26
|
+
for (const d of r.direct)
|
|
27
|
+
out.push(` ${mark(d.severity)} ${d.id} [${d.severity}] ${d.statement}`);
|
|
28
|
+
}
|
|
29
|
+
if (r.near.length) {
|
|
30
|
+
out.push(`\nInvariants reached via blast radius (${r.near.length}, advisory):`);
|
|
31
|
+
for (const n of r.near)
|
|
32
|
+
out.push(` ${mark(n.severity)} ${n.id} [${n.severity}] ${n.statement}\n via ${n.via[0] ?? ""}`);
|
|
33
|
+
}
|
|
34
|
+
if (im.decisions.length) {
|
|
35
|
+
const cap = 10;
|
|
36
|
+
out.push(`\nDecisions concerning the touched files (${im.decisions.length}):`);
|
|
37
|
+
for (const d of im.decisions.slice(0, cap))
|
|
38
|
+
out.push(` • ${d.id} [${d.status}] ${clip(d.title, 100)}`);
|
|
39
|
+
if (im.decisions.length > cap)
|
|
40
|
+
out.push(` …(+${im.decisions.length - cap} more)`);
|
|
41
|
+
}
|
|
42
|
+
if (!r.direct.length && !r.near.length && !im.decisions.length) {
|
|
43
|
+
out.push("\nNo recorded invariants or decisions touch this change.");
|
|
44
|
+
}
|
|
45
|
+
return out.join("\n");
|
|
46
|
+
}
|
|
9
47
|
/** True when --strict should FAIL the commit/PR. */
|
|
10
48
|
export function reportFailsStrict(r) {
|
|
11
49
|
return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0 || r.vetoBlocking > 0);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { currentForTopic, rejectedForTopic } from "./topics.js";
|
|
2
|
+
const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))?\s*-->/g;
|
|
3
|
+
/** Parse every hunch:topic marker out of a markdown document. */
|
|
4
|
+
export function parseDocAnchors(text) {
|
|
5
|
+
const out = [];
|
|
6
|
+
MARKER.lastIndex = 0;
|
|
7
|
+
let m;
|
|
8
|
+
while ((m = MARKER.exec(text))) {
|
|
9
|
+
out.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, m.index).split("\n").length });
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
const clip = (s, n = 220) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
|
|
14
|
+
/** Pre-edit grounding for a markdown document that carries topic anchors: the
|
|
15
|
+
* CURRENT decision per declared topic (graph over prose), what it rejected,
|
|
16
|
+
* and a stale-pin warning the editor can heal inline. Empty when no anchor
|
|
17
|
+
* resolves to a decision. */
|
|
18
|
+
export function renderDocGrounding(anchors, decisions) {
|
|
19
|
+
const parts = [];
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
for (const a of anchors) {
|
|
22
|
+
if (seen.has(a.topic))
|
|
23
|
+
continue;
|
|
24
|
+
seen.add(a.topic);
|
|
25
|
+
const current = currentForTopic(decisions, a.topic);
|
|
26
|
+
if (!current)
|
|
27
|
+
continue;
|
|
28
|
+
let line = `• topic "${a.topic}" → current decision ${current.id} — "${current.title}": ${clip(current.decision)}`;
|
|
29
|
+
const rejected = rejectedForTopic(decisions, a.topic);
|
|
30
|
+
if (rejected.length)
|
|
31
|
+
line += `\n rejected: ${rejected.slice(0, 3).map((r) => clip(r, 90)).join("; ")}`;
|
|
32
|
+
if (a.pin && a.pin !== current.id) {
|
|
33
|
+
line += `\n ⚠ this section is PINNED to ${a.pin}, which is no longer current — reconcile the prose with ${current.id}, then re-pin.`;
|
|
34
|
+
}
|
|
35
|
+
parts.push(line);
|
|
36
|
+
}
|
|
37
|
+
if (!parts.length)
|
|
38
|
+
return "";
|
|
39
|
+
return `🧭 Doc-grounding — this document declares topic anchors; the GRAPH is the source of truth. Follow the current decision, update prose to match it:\n${parts.join("\n")}`;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=docanchors.js.map
|
package/dist/core/drift.js
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
|
12
12
|
import { join, extname } from "node:path";
|
|
13
13
|
import { toPosixTarget } from "./paths.js";
|
|
14
14
|
import { currentForTopic, isLive } from "./topics.js";
|
|
15
|
+
import { parseDocAnchors } from "./docanchors.js";
|
|
15
16
|
const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
|
|
16
17
|
const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
|
|
17
18
|
export function computeDrift(store, root) {
|
|
@@ -72,16 +73,39 @@ export function computeDrift(store, root) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
|
-
//
|
|
76
|
-
// referencing code that exists. Heuristic + advisory; scoped to the repo's own
|
|
77
|
-
// markdown (node_modules and sub-projects skipped).
|
|
76
|
+
// One markdown pass feeds both prose checks (3 + 5): read each doc once.
|
|
78
77
|
for (const doc of markdownDocs(root)) {
|
|
79
78
|
const text = safeRead(doc.path);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
79
|
+
// 3. DOC-STALE — a doc that still advertises "proposed / not implemented" while
|
|
80
|
+
// referencing code that exists. Heuristic + advisory; scoped to the repo's own
|
|
81
|
+
// markdown (node_modules and sub-projects skipped).
|
|
82
|
+
if (STALE_MARKER.test(text.slice(0, 1500))) {
|
|
83
|
+
const existing = (text.match(SRC_REF) ?? []).find((r) => existsSync(join(root, r)));
|
|
84
|
+
if (existing) {
|
|
85
|
+
findings.push({ kind: "doc-stale", id: doc.rel, detail: `marked proposed/not-implemented but references shipped code (${existing})` });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// 5. DOC-ANCHORS (prose≠graph, decision-grounding for markdown) — a section
|
|
89
|
+
// PINNED via `<!-- hunch:topic <topic> <dec_id> -->` to a decision that has
|
|
90
|
+
// been superseded (doc-anchor-stale, the CI-gateable one) or that doesn't
|
|
91
|
+
// exist (doc-anchor-dangling). Unpinned markers only ground the pre-edit
|
|
92
|
+
// hook — an explicit pin is the ONLY thing that can fire drift here.
|
|
93
|
+
for (const a of parseDocAnchors(text)) {
|
|
94
|
+
if (!a.pin)
|
|
95
|
+
continue;
|
|
96
|
+
const pinned = byId.get(a.pin);
|
|
97
|
+
if (!pinned) {
|
|
98
|
+
findings.push({ kind: "doc-anchor-dangling", id: doc.rel, detail: `line ${a.line}: pinned to ${a.pin} (topic "${a.topic}"), which does not exist` });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const current = currentForTopic(decisions, a.topic);
|
|
102
|
+
if ((pinned.status === "superseded" || pinned.superseded_by) && current && current.id !== a.pin) {
|
|
103
|
+
findings.push({
|
|
104
|
+
kind: "doc-anchor-stale",
|
|
105
|
+
id: doc.rel,
|
|
106
|
+
detail: `line ${a.line}: prose pinned to superseded ${a.pin} (topic "${a.topic}"); the current decision is ${current.id} — "${current.title}". Reconcile the prose with it, then re-pin.`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
85
109
|
}
|
|
86
110
|
}
|
|
87
111
|
return { findings };
|
package/dist/core/paths.js
CHANGED
|
@@ -37,11 +37,13 @@ export function hunchPathsForDir(hunchDir) {
|
|
|
37
37
|
dir: (kind) => join(hunch, kind),
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
-
/** Walk up from `start` to
|
|
41
|
-
*
|
|
40
|
+
/** Walk up from `start` to the nearest dir containing a .hunch/ dir OR a .git
|
|
41
|
+
* (repo boundary), else `start`. Lets `hunch` run from subdirs. A `.git`
|
|
42
|
+
* WITHOUT `.hunch` stops the walk: an ancestor `.hunch` above the repo
|
|
43
|
+
* boundary belongs to some other scope (e.g. a stray ~/.hunch) and must never
|
|
44
|
+
* hijack a fresh repo — init would scaffold, index, and scan OUTSIDE the repo. */
|
|
42
45
|
export function findRoot(start = process.cwd()) {
|
|
43
46
|
let cur = resolve(start);
|
|
44
|
-
let gitFallback = null;
|
|
45
47
|
const isDir = (p) => {
|
|
46
48
|
try {
|
|
47
49
|
return statSync(p).isDirectory();
|
|
@@ -53,13 +55,13 @@ export function findRoot(start = process.cwd()) {
|
|
|
53
55
|
for (;;) {
|
|
54
56
|
if (isDir(join(cur, HUNCH_DIR)))
|
|
55
57
|
return cur; // a `.hunch` regular file is not a root
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
+
if (existsSync(join(cur, ".git")))
|
|
59
|
+
return cur; // repo boundary — .git file (worktree) counts
|
|
58
60
|
const parent = dirname(cur);
|
|
59
61
|
if (parent === cur)
|
|
60
62
|
break;
|
|
61
63
|
cur = parent;
|
|
62
64
|
}
|
|
63
|
-
return
|
|
65
|
+
return resolve(start);
|
|
64
66
|
}
|
|
65
67
|
//# sourceMappingURL=paths.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -22,7 +22,7 @@ import { ensureTeamOverlay } from "../integrations/team.js";
|
|
|
22
22
|
import { formatContext } from "../core/format.js";
|
|
23
23
|
import { compareCandidates } from "../core/compare.js";
|
|
24
24
|
import { checkConformance } from "../core/conformance.js";
|
|
25
|
-
import { renderMarkdown, verdict } from "../core/checkreport.js";
|
|
25
|
+
import { renderMarkdown, renderImpact, verdict } from "../core/checkreport.js";
|
|
26
26
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
27
27
|
import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
|
|
28
28
|
import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
|
|
@@ -528,6 +528,62 @@ export function buildServer(root) {
|
|
|
528
528
|
return err(`Failed to compute merge verdict: ${e.message}`);
|
|
529
529
|
}
|
|
530
530
|
});
|
|
531
|
+
// -- hunch_pr_impact (read-only impact surface — advisory, never gates) ----
|
|
532
|
+
server.registerTool("hunch_pr_impact", {
|
|
533
|
+
title: "PR impact: the dependency + memory surface of a change",
|
|
534
|
+
description: "Given a change (staged, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base AND commit for staged changes.",
|
|
535
|
+
inputSchema: {
|
|
536
|
+
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
537
|
+
commit: z.string().optional().describe("Impact of a single commit (sha/ref). Omit base AND commit for staged changes."),
|
|
538
|
+
},
|
|
539
|
+
}, async ({ base, commit }) => {
|
|
540
|
+
try {
|
|
541
|
+
if (base && commit)
|
|
542
|
+
return err("Pass at most one of base/commit (omit both for staged changes).");
|
|
543
|
+
if (base && !revExists(base, root))
|
|
544
|
+
return err(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
|
|
545
|
+
if (commit && !revExists(commit, root))
|
|
546
|
+
return err(`commit "${commit}" does not resolve.`);
|
|
547
|
+
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
|
|
548
|
+
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : "staged changes";
|
|
549
|
+
if (!files.length)
|
|
550
|
+
return ok(`No changed files in ${scope}.`);
|
|
551
|
+
const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
|
|
552
|
+
return ok(renderImpact(store.prImpact(files, diff), scope));
|
|
553
|
+
}
|
|
554
|
+
catch (e) {
|
|
555
|
+
return err(`Failed to compute impact: ${e.message}`);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
// -- hunch_path (shortest dependency chain) --------------------------------
|
|
559
|
+
server.registerTool("hunch_path", {
|
|
560
|
+
title: "Shortest dependency path between two nodes",
|
|
561
|
+
description: "How does A reach B? Returns the shortest chain of call/import/dependency/contains edges connecting two symbols, files, or components — walked in either direction. Use to understand coupling before a refactor, to verify the actual route behind a must-reach invariant, or to explain why editing A shows up in B's blast radius. Deterministic, read-only.",
|
|
562
|
+
inputSchema: {
|
|
563
|
+
from: z.string().describe("Start: a symbol id/name or file path."),
|
|
564
|
+
to: z.string().describe("End: a symbol id/name or file path."),
|
|
565
|
+
max_depth: z.number().optional().describe("Maximum hops to search (default 8)."),
|
|
566
|
+
},
|
|
567
|
+
}, async ({ from, to, max_depth }) => {
|
|
568
|
+
const A = store.resolveNodeIds(from);
|
|
569
|
+
const B = store.resolveNodeIds(to);
|
|
570
|
+
if (!A.length)
|
|
571
|
+
return err(`"${from}" resolves to no indexed symbol/component (is the repo indexed?).`);
|
|
572
|
+
if (!B.length)
|
|
573
|
+
return err(`"${to}" resolves to no indexed symbol/component.`);
|
|
574
|
+
let best = null;
|
|
575
|
+
for (const a of A.slice(0, 4)) {
|
|
576
|
+
for (const b of B.slice(0, 4)) {
|
|
577
|
+
const p = store.shortestPath(a, b, max_depth ?? 8);
|
|
578
|
+
if (p && (!best || p.length < best.length))
|
|
579
|
+
best = p;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (!best)
|
|
583
|
+
return ok(`No path between "${from}" and "${to}" within ${max_depth ?? 8} hop(s) — they are not connected in the indexed graph.`);
|
|
584
|
+
const chain = best.map((n, i) => ` ${i === 0 ? "┌" : i === best.length - 1 ? "└" : "├"} ${n.via}`).join("\n");
|
|
585
|
+
return ok(`${best.length - 1} hop(s) from "${from}" to "${to}":\n${chain}`);
|
|
586
|
+
});
|
|
531
587
|
// -- hunch_compare --------------------------------------------------------
|
|
532
588
|
server.registerTool("hunch_compare", {
|
|
533
589
|
title: "Rank candidate solutions by architectural fit",
|
package/dist/store/db.js
CHANGED
|
@@ -1,19 +1,57 @@
|
|
|
1
|
-
/** Thin wrapper around
|
|
2
|
-
import
|
|
1
|
+
/** Thin wrapper around node:sqlite for the derived index. */
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
3
|
import { mkdirSync } from "node:fs";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { SCHEMA_SQL } from "./schema.js";
|
|
6
|
+
/** Load node:sqlite while swallowing ONLY its ExperimentalWarning (Node 22–24 still
|
|
7
|
+
* emits it on module load). Hunch's stderr reaches humans, hooks, and MCP clients on
|
|
8
|
+
* every invocation, so the noise would land everywhere; all other warnings pass through. */
|
|
9
|
+
function loadSqlite() {
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const realEmit = process.emitWarning.bind(process);
|
|
12
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
13
|
+
if (String(warning).includes("SQLite is an experimental feature"))
|
|
14
|
+
return;
|
|
15
|
+
realEmit(warning, ...rest);
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
return require("node:sqlite");
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
process.emitWarning = realEmit;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const sqlite = loadSqlite();
|
|
6
25
|
export function openDb(sqlitePath) {
|
|
7
26
|
mkdirSync(dirname(sqlitePath), { recursive: true });
|
|
8
|
-
const db = new
|
|
9
|
-
db.
|
|
27
|
+
const db = new sqlite.DatabaseSync(sqlitePath);
|
|
28
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
10
29
|
db.exec(SCHEMA_SQL);
|
|
11
30
|
return db;
|
|
12
31
|
}
|
|
13
32
|
/** In-memory db (tests / ephemeral queries). */
|
|
14
33
|
export function openMemoryDb() {
|
|
15
|
-
const db = new
|
|
34
|
+
const db = new sqlite.DatabaseSync(":memory:");
|
|
16
35
|
db.exec(SCHEMA_SQL);
|
|
17
36
|
return db;
|
|
18
37
|
}
|
|
38
|
+
/** Run `fn` inside one transaction: BEGIN → fn → COMMIT, ROLLBACK on throw.
|
|
39
|
+
* (node:sqlite has no better-sqlite3-style transaction() helper.) */
|
|
40
|
+
export function withTx(db, fn) {
|
|
41
|
+
db.exec("BEGIN");
|
|
42
|
+
try {
|
|
43
|
+
const out = fn();
|
|
44
|
+
db.exec("COMMIT");
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
try {
|
|
49
|
+
db.exec("ROLLBACK");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* connection already rolled back */
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
19
57
|
//# sourceMappingURL=db.js.map
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -14,7 +14,7 @@ import { resolve, join } from "node:path";
|
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { toPosixTarget, hunchPathsForDir } from "../core/paths.js";
|
|
16
16
|
import { ENTITY_KINDS } from "../core/types.js";
|
|
17
|
-
import { openDb } from "./db.js";
|
|
17
|
+
import { openDb, withTx } from "./db.js";
|
|
18
18
|
import { RESET_SQL, embedHash } from "./schema.js";
|
|
19
19
|
import { selectEmbedder } from "./embedder.js";
|
|
20
20
|
import { JsonStore } from "./jsonStore.js";
|
|
@@ -188,7 +188,7 @@ export class HunchStore {
|
|
|
188
188
|
reindex() {
|
|
189
189
|
const db = this.db;
|
|
190
190
|
const counts = {};
|
|
191
|
-
|
|
191
|
+
withTx(db, () => {
|
|
192
192
|
db.exec(RESET_SQL);
|
|
193
193
|
const j = (s) => s; // readability marker for JSON-encoded columns
|
|
194
194
|
// Prepare the FTS insert ONCE (after RESET created the table), not per row.
|
|
@@ -263,7 +263,6 @@ export class HunchStore {
|
|
|
263
263
|
counts.runbooks = runbooks.length;
|
|
264
264
|
void j;
|
|
265
265
|
});
|
|
266
|
-
tx();
|
|
267
266
|
// Reconcile embeddings AFTER the FTS rebuild (model-free): drop vectors whose
|
|
268
267
|
// source doc vanished or whose text changed. Embeddings are NOT in RESET_SQL,
|
|
269
268
|
// so this is what keeps them coherent across the many reindex() call sites.
|
|
@@ -317,14 +316,13 @@ export class HunchStore {
|
|
|
317
316
|
const rows = this.db.prepare(`SELECT ref, doc_hash FROM embeddings`).all();
|
|
318
317
|
const del = this.db.prepare(`DELETE FROM embeddings WHERE ref = ?`);
|
|
319
318
|
let pruned = 0;
|
|
320
|
-
|
|
319
|
+
withTx(this.db, () => {
|
|
321
320
|
for (const r of rows)
|
|
322
321
|
if (live.get(r.ref) !== r.doc_hash) {
|
|
323
322
|
del.run(r.ref);
|
|
324
323
|
pruned++;
|
|
325
324
|
}
|
|
326
325
|
});
|
|
327
|
-
tx();
|
|
328
326
|
return pruned;
|
|
329
327
|
}
|
|
330
328
|
/** Embedding coverage for a model: up-to-date vectors vs total docs (doctor). */
|
|
@@ -357,7 +355,7 @@ export class HunchStore {
|
|
|
357
355
|
for (let i = 0; i < todo.length; i += batchSize) {
|
|
358
356
|
const slice = todo.slice(i, i + batchSize);
|
|
359
357
|
const vecs = await embedder.embed(slice.map((d) => `${d.title}\n${d.body}`));
|
|
360
|
-
|
|
358
|
+
withTx(this.db, () => {
|
|
361
359
|
slice.forEach((d, j) => {
|
|
362
360
|
const v = vecs[j];
|
|
363
361
|
if (v) {
|
|
@@ -366,7 +364,6 @@ export class HunchStore {
|
|
|
366
364
|
}
|
|
367
365
|
});
|
|
368
366
|
});
|
|
369
|
-
tx();
|
|
370
367
|
attempted += slice.length;
|
|
371
368
|
opts.onProgress?.(attempted, todo.length);
|
|
372
369
|
}
|
|
@@ -635,6 +632,79 @@ export class HunchStore {
|
|
|
635
632
|
WHERE up.depth > 0 AND s.file <> ?
|
|
636
633
|
GROUP BY s.file ORDER BY depth, file`).all(file, maxDepth, file);
|
|
637
634
|
}
|
|
635
|
+
/** Shortest undirected path between two graph nodes (symbols/components) over
|
|
636
|
+
* call/dep/import/contains edges — "how does A reach B?" (hunch path / hunch_path).
|
|
637
|
+
* BFS via a recursive CTE; visited ids ride a |-delimited list so cycles terminate.
|
|
638
|
+
* Returns the node chain in order, or null when no path exists within maxDepth. */
|
|
639
|
+
shortestPath(fromId, toId, maxDepth = 8) {
|
|
640
|
+
if (fromId === toId)
|
|
641
|
+
return [{ id: fromId, via: this.nodeLabel(fromId) }];
|
|
642
|
+
const row = this.db.prepare(
|
|
643
|
+
/* sql */ `
|
|
644
|
+
WITH RECURSIVE step(node, path, depth) AS (
|
|
645
|
+
SELECT ?, '|' || ? || '|', 0
|
|
646
|
+
UNION
|
|
647
|
+
SELECT x.nb, step.path || x.nb || '|', step.depth + 1
|
|
648
|
+
FROM (
|
|
649
|
+
SELECT e."from" AS frm, e."to" AS nb FROM edges e WHERE e.type IN ('calls','depends_on','imports','contains')
|
|
650
|
+
UNION ALL
|
|
651
|
+
SELECT e."to" AS frm, e."from" AS nb FROM edges e WHERE e.type IN ('calls','depends_on','imports','contains')
|
|
652
|
+
) x JOIN step ON x.frm = step.node
|
|
653
|
+
WHERE step.depth < ? AND instr(step.path, '|' || x.nb || '|') = 0
|
|
654
|
+
)
|
|
655
|
+
SELECT path FROM step WHERE node = ? ORDER BY depth LIMIT 1`).get(fromId, fromId, maxDepth, toId);
|
|
656
|
+
if (!row)
|
|
657
|
+
return null;
|
|
658
|
+
const ids = row.path.split("|").filter(Boolean);
|
|
659
|
+
return ids.map((id) => ({ id, via: this.nodeLabel(id) }));
|
|
660
|
+
}
|
|
661
|
+
/** Human label for a graph node id: "name @ file" for a symbol, the component name,
|
|
662
|
+
* or the id itself when unindexed. */
|
|
663
|
+
nodeLabel(id) {
|
|
664
|
+
const s = this.db.prepare(`SELECT name || ' @ ' || file AS v FROM symbols WHERE id = ?`).get(id);
|
|
665
|
+
if (s)
|
|
666
|
+
return s.v;
|
|
667
|
+
const c = this.db.prepare(`SELECT name AS v FROM components WHERE id = ?`).get(id);
|
|
668
|
+
return c?.v ?? id;
|
|
669
|
+
}
|
|
670
|
+
/** Resolve a free-form target (symbol id / name / file path, component id / name)
|
|
671
|
+
* to graph node ids — symbols win over components, exact file before suffix. */
|
|
672
|
+
resolveNodeIds(target) {
|
|
673
|
+
const t = toPosixTarget(target);
|
|
674
|
+
const sym = this.db.prepare(`SELECT id FROM symbols WHERE id = ? OR name = ? OR file = ? OR file LIKE ? LIMIT 20`).all(t, t, t, `%/${t}`);
|
|
675
|
+
if (sym.length)
|
|
676
|
+
return sym.map((r) => r.id);
|
|
677
|
+
const cmp = this.db.prepare(`SELECT id FROM components WHERE id = ? OR name = ? LIMIT 5`).all(t, t);
|
|
678
|
+
return cmp.map((r) => r.id);
|
|
679
|
+
}
|
|
680
|
+
/** PR impact (read-only, ADVISORY — never gates): the dependency + memory surface
|
|
681
|
+
* of a change. Composes the SAME primitives as buildCheckReport (blast radius,
|
|
682
|
+
* scope-matched constraints, why) so impact and gating can never disagree. */
|
|
683
|
+
prImpact(files, diff) {
|
|
684
|
+
const changed = new Set(files.map(toPosixTarget));
|
|
685
|
+
const blast = new Map();
|
|
686
|
+
for (const f of changed) {
|
|
687
|
+
for (const b of this.blastRadiusFiles(f)) {
|
|
688
|
+
if (changed.has(b.file))
|
|
689
|
+
continue;
|
|
690
|
+
const prev = blast.get(b.file);
|
|
691
|
+
if (!prev || b.depth < prev.depth)
|
|
692
|
+
blast.set(b.file, b);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const report = this.buildCheckReport([...changed], diff, { strict: false });
|
|
696
|
+
const decisions = new Map();
|
|
697
|
+
for (const f of changed) {
|
|
698
|
+
for (const d of this.why(f).decisions)
|
|
699
|
+
decisions.set(d.id, { id: d.id, title: d.title, status: d.status });
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
files: [...changed],
|
|
703
|
+
blast: [...blast.values()].sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file)),
|
|
704
|
+
report,
|
|
705
|
+
decisions: [...decisions.values()],
|
|
706
|
+
};
|
|
707
|
+
}
|
|
638
708
|
/** Constraints whose scope glob matches a path/glob (hunch_check_constraints).
|
|
639
709
|
* By default only ACTIVE invariants are returned — a retired constraint is no
|
|
640
710
|
* longer enforced. Pass `{ asOf }` to instead return the invariants in force at
|
|
@@ -1167,7 +1237,7 @@ function numEnv(name, dflt) {
|
|
|
1167
1237
|
}
|
|
1168
1238
|
/** Pack a vector's exact bytes for SQLite. Explicit offset+length so a SUBARRAY
|
|
1169
1239
|
* view (byteOffset != 0) writes only its slice, not the whole backing buffer.
|
|
1170
|
-
*
|
|
1240
|
+
* node:sqlite copies on bind, so the returned view never aliases the row. */
|
|
1171
1241
|
function vecToBlob(v) {
|
|
1172
1242
|
return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
|
|
1173
1243
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
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).",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"developer-tools"
|
|
36
36
|
],
|
|
37
37
|
"engines": {
|
|
38
|
-
"node": ">=
|
|
38
|
+
"node": ">=22.13.0"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"clean": "node --input-type=commonjs -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
@@ -48,15 +48,13 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
51
|
-
"better-sqlite3": "12.9.0",
|
|
52
51
|
"commander": "^15.0.0",
|
|
53
52
|
"tree-sitter": "0.21.1",
|
|
54
53
|
"tree-sitter-typescript": "^0.23.2",
|
|
55
54
|
"zod": "^4.4.3"
|
|
56
55
|
},
|
|
57
56
|
"devDependencies": {
|
|
58
|
-
"@types/
|
|
59
|
-
"@types/node": "^20.19.0",
|
|
57
|
+
"@types/node": "^22.13.0",
|
|
60
58
|
"tsx": "^4.22.4",
|
|
61
59
|
"typescript": "^5.9.3"
|
|
62
60
|
},
|