@davesheffer/hunch 1.1.0 → 1.2.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 +36 -51
- package/dist/cli/index.js +17 -1
- package/dist/core/format.js +46 -0
- package/dist/mcp/server.js +9 -1
- package/dist/store/hunchStore.js +64 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,35 +105,21 @@ is its complement — it keeps your *docs* honest to the graph (**doc ≠ graph*
|
|
|
105
105
|
says one thing; the decision that actually governs the file says another. Both are "memory that stays
|
|
106
106
|
true"; you want both.
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
It only fires on **explicit** topic anchors — no semantic guessing, no false positives on prose it can't
|
|
126
|
-
read.
|
|
127
|
-
- **Capture, gated.** `hunch_record_decision` now enforces a store-scoped **uniqueness guard**: it refuses
|
|
128
|
-
a *second* live decision for a topic (you're never silently governed by two). The richer path is the new
|
|
129
|
-
**`hunch_capture_decision`** tool — it returns a one-question-at-a-time grilling protocol plus a
|
|
130
|
-
capture-session token; `record_decision` accepts an optional `capture_token`. Un-token'd writes still
|
|
131
|
-
work, they just get a nudge toward `/capture`. **`hunch_current_decision(topic)`** returns the one answer
|
|
132
|
-
that currently governs a topic.
|
|
133
|
-
- **`hunch reconcile-topics`.** A git merge is the one thing that can create two live decisions for a
|
|
134
|
-
topic. This scans for it and exits non-zero — wire it into a post-merge hook or CI.
|
|
135
|
-
- **`hunch heal`** + the **`/capture`** and **`/heal`** slash commands (scaffolded by `hunch init`) do
|
|
136
|
-
**read-only** doc↔graph reconciliation — they surface the mismatch and never rewrite your prose silently.
|
|
108
|
+
A decision can be anchored to a **topic** (e.g. `"auth.session"`), and a topic always has one live
|
|
109
|
+
answer: the **current** decision, its **history**, and what was **rejected** along the way. Existing
|
|
110
|
+
graphs are unaffected until you opt in.
|
|
111
|
+
|
|
112
|
+
- **Read-time grounding.** Before the AI edits a file — or a markdown doc like `AGENTS.md` — it's told
|
|
113
|
+
which decision is *current* (follow the graph, not a stale doc) and what was **rejected**, so it
|
|
114
|
+
doesn't re-add the approach you already ruled out.
|
|
115
|
+
- **Drift, caught deterministically.** Prose or a file still describing a **superseded** decision is
|
|
116
|
+
flagged in `hunch doctor`, and `hunch drift` exits non-zero so CI can gate on it. It only fires on
|
|
117
|
+
explicit anchors — never a semantic guess.
|
|
118
|
+
- **Capture, interviewed.** `/capture` walks a decision to a resolved state — topic, rationale, and
|
|
119
|
+
rejected alternatives — before it's written, and the graph refuses to hold **two live decisions on
|
|
120
|
+
one topic**. `hunch reconcile-topics` catches the one case a git merge can create, for human resolution.
|
|
121
|
+
- **`hunch heal`** + the **`/heal`** slash command do **read-only** doc↔graph reconciliation — they show
|
|
122
|
+
exactly what disagrees and never rewrite your prose silently.
|
|
137
123
|
|
|
138
124
|
→ [docs](https://hunch-pi.vercel.app/docs#grounding)
|
|
139
125
|
|
|
@@ -145,8 +131,18 @@ cd your-repo
|
|
|
145
131
|
hunch init # scaffold .hunch/, index, install hooks, wire up assistants
|
|
146
132
|
hunch backfill --since 90d # cold start: seed decisions from recent git history
|
|
147
133
|
hunch why src/auth/session.ts # …then ask your assistant: "why is X built this way?"
|
|
134
|
+
hunch structure src/auth # the map, from the graph — no grep rounds
|
|
148
135
|
```
|
|
149
136
|
|
|
137
|
+
**Claude Code users — one-step plugin install** (MCP tools + `/hunch:capture`, `/hunch:heal`, `/hunch:why`, `/hunch:fix`, `/hunch:fragile`):
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
/plugin marketplace add davesheffer/hunch
|
|
141
|
+
/plugin install hunch@hunch
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then `hunch init` in each repo you want remembered (the plugin brings the tools; init builds the graph + hooks).
|
|
145
|
+
|
|
150
146
|
`hunch init` scaffolds `.hunch/`, indexes the repo, installs the git hooks,
|
|
151
147
|
writes `.mcp.json` + slash commands + an auto-maintained `CLAUDE.md`, and wires up **every
|
|
152
148
|
detected assistant** (Claude Code, Cursor, VS Code/Copilot, Windsurf, Codex, Google Antigravity) to the same
|
|
@@ -230,12 +226,10 @@ Plus the **Regression Guard** (re-adding deliberately-retired code) and the
|
|
|
230
226
|
comments the affected `con_`/`dec_` ids and fails on a blocking one).
|
|
231
227
|
|
|
232
228
|
Name the actual violation — `record-constraint "…" --scope "src/**" --severity blocking
|
|
233
|
-
--forbid-dep "lodash"` — and it blocks the *real* change
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
automatically. (`--match <regex>` remains a lint-grade textual fallback.) None of these are a
|
|
238
|
-
bypass-proof boundary — deliberate indirection can still route around any rule.
|
|
229
|
+
--forbid-dep "lodash"` — and it blocks the *real* change for the file's whole life, while staying
|
|
230
|
+
quiet on edits that don't break the rule. A comment or string that merely mentions the module can't
|
|
231
|
+
false-positive. None of these are a bypass-proof boundary — deliberate indirection can still route
|
|
232
|
+
around any rule.
|
|
239
233
|
|
|
240
234
|
## Working as a team
|
|
241
235
|
|
|
@@ -263,24 +257,15 @@ the same way via the MCP server) — everyone, on every branch, resolves the sam
|
|
|
263
257
|
## Private memory (public repo, private context)
|
|
264
258
|
|
|
265
259
|
Open-source your code without open-sourcing your *reasoning*. **`hunch private`** sets up a
|
|
266
|
-
separate private store in one command
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
the CI PR comment render *public-only*, so a private record can't reach a public surface. Record
|
|
272
|
-
sensitive items with `private: true` (`hunch_record_decision` / `hunch_record_correction`);
|
|
273
|
-
post-commit synthesis can route there too. Every capture is **auto-committed by default** to the
|
|
274
|
-
store it lands in — the private repo is committed + pushed; a public capture is committed to
|
|
275
|
-
`.hunch/` only and rides your next push (Hunch never pushes or merges your code branch) —
|
|
276
|
-
recursion-safe, staging only `.hunch/`. Opt out with `--no-auto-commit`.
|
|
260
|
+
separate private store in one command: your local queries, guards, and assistants see the
|
|
261
|
+
sensitive decisions — but they're never committed to the public repo, and public outputs (like
|
|
262
|
+
the CI PR comment) **can never contain them, by construction**. Opt-in, default-off; captures are
|
|
263
|
+
auto-committed to the store they land in (opt out with `--no-auto-commit`), and Hunch never
|
|
264
|
+
touches your code branch.
|
|
277
265
|
|
|
278
266
|
Already published a repo *with* its `.hunch/` memory and want it private after the fact?
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
gitignores the `.hunch/` memory tree, and regenerates the assistant grounding (CLAUDE.md, AGENTS.md,
|
|
282
|
-
…) so the repo becomes **code-only**. It commits the private overlay for you and prints the one
|
|
283
|
-
`git` command to commit the now-clean public repo.
|
|
267
|
+
**`hunch private --repo <url> --migrate`** moves the existing memory into the private store —
|
|
268
|
+
nothing lost — and leaves the public repo code-only, telling you the one `git` command left to run.
|
|
284
269
|
→ [docs](https://hunch-pi.vercel.app/docs#private)
|
|
285
270
|
|
|
286
271
|
## Continuous learning (CI)
|
package/dist/cli/index.js
CHANGED
|
@@ -45,7 +45,7 @@ import { updateClaudeMd } from "../integrations/claudemd.js";
|
|
|
45
45
|
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
46
46
|
import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding } from "../integrations/providers.js";
|
|
47
47
|
import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
|
|
48
|
-
import { formatContext } from "../core/format.js";
|
|
48
|
+
import { formatContext, formatStructure } from "../core/format.js";
|
|
49
49
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
50
50
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
51
51
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
@@ -708,6 +708,7 @@ program
|
|
|
708
708
|
const { store, root } = storeFor();
|
|
709
709
|
// Lookup mode: scoped runbook retrieval (search within runbooks, not the whole graph).
|
|
710
710
|
if (opts.find) {
|
|
711
|
+
store.reindex(); // reflect out-of-band JSON edits before searching (mirrors `hunch query`)
|
|
711
712
|
const emb = opts.semantic ? await selectEmbedder() : undefined;
|
|
712
713
|
const hits = await store.searchRunbooks(opts.find, 5, { embedder: emb });
|
|
713
714
|
if (!hits.length)
|
|
@@ -1842,6 +1843,21 @@ program
|
|
|
1842
1843
|
store.close();
|
|
1843
1844
|
}
|
|
1844
1845
|
});
|
|
1846
|
+
// ---- structure (graph-served orientation — the anti-grep) -------------------
|
|
1847
|
+
program
|
|
1848
|
+
.command("structure")
|
|
1849
|
+
.description("The indexed shape of the repo, a directory, a file, or a symbol — orient from the graph instead of grep rounds. No target: repo map. Read-only.")
|
|
1850
|
+
.argument("[target]", "a directory, file path, or exact symbol name (omit for the repo map)")
|
|
1851
|
+
.action((target) => {
|
|
1852
|
+
const { store } = storeFor();
|
|
1853
|
+
try {
|
|
1854
|
+
store.reindex(); // reflect out-of-band JSON edits before reading the graph
|
|
1855
|
+
console.log(formatStructure(store.structure(target)));
|
|
1856
|
+
}
|
|
1857
|
+
finally {
|
|
1858
|
+
store.close();
|
|
1859
|
+
}
|
|
1860
|
+
});
|
|
1845
1861
|
// ---- impact (PR impact — read-only, advisory) ------------------------------
|
|
1846
1862
|
program
|
|
1847
1863
|
.command("impact")
|
package/dist/core/format.js
CHANGED
|
@@ -43,4 +43,50 @@ export function formatContext(ctx) {
|
|
|
43
43
|
trimmed = trimmed.slice(0, lastNl);
|
|
44
44
|
return trimmed + "\n… (trimmed to budget)\n";
|
|
45
45
|
}
|
|
46
|
+
/** Render a StructureView as a compact orientation brief (hunch_structure). */
|
|
47
|
+
export function formatStructure(v) {
|
|
48
|
+
const NL = "\n";
|
|
49
|
+
if (v.kind === "none")
|
|
50
|
+
return `Nothing indexed matches "${v.target}" — not a known file, directory, or symbol. Run hunch index if the repo changed, or hunch_query for fuzzy search.`;
|
|
51
|
+
if (v.kind === "repo") {
|
|
52
|
+
const out = [`# Repo structure (from the graph — no grep needed)`];
|
|
53
|
+
if (v.components.length) {
|
|
54
|
+
out.push(`${NL}## Components`);
|
|
55
|
+
for (const c of v.components)
|
|
56
|
+
out.push(`- ${c.name}: ${c.responsibility} (${c.paths.join(", ")})`);
|
|
57
|
+
}
|
|
58
|
+
out.push(`${NL}## Directories (by symbol count)`);
|
|
59
|
+
for (const d of v.dirs.slice(0, 20))
|
|
60
|
+
out.push(`- ${d.dir} — ${d.files} file(s), ${d.symbols} symbol(s)`);
|
|
61
|
+
if (v.dirs.length > 20)
|
|
62
|
+
out.push(` …(+${v.dirs.length - 20} more)`);
|
|
63
|
+
return out.join(NL);
|
|
64
|
+
}
|
|
65
|
+
if (v.kind === "dir") {
|
|
66
|
+
const out = [`# ${v.dir}/ — ${v.files.length} indexed file(s)`];
|
|
67
|
+
for (const f of v.files) {
|
|
68
|
+
const syms = f.symbols.slice(0, 8).map((s) => `${s.name}${s.fan_in ? ` (fan-in ${s.fan_in})` : ""}`).join(", ");
|
|
69
|
+
out.push(`- ${f.file}: ${syms}${f.symbols.length > 8 ? ` …(+${f.symbols.length - 8})` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
return out.join(NL);
|
|
72
|
+
}
|
|
73
|
+
if (v.kind === "file") {
|
|
74
|
+
const out = [`# ${v.file} — outline (${v.symbols.length} symbol(s))`];
|
|
75
|
+
for (const sy of v.symbols) {
|
|
76
|
+
out.push(`- ${sy.name} [${sy.kind}] loc ${sy.loc}, fan-in ${sy.fan_in}, fan-out ${sy.fan_out}`);
|
|
77
|
+
if (sy.callers.length)
|
|
78
|
+
out.push(` called by: ${sy.callers.join("; ")}`);
|
|
79
|
+
}
|
|
80
|
+
return out.join(NL);
|
|
81
|
+
}
|
|
82
|
+
const out = [`# "${v.matches[0]?.name}" — ${v.matches.length} definition site(s)`];
|
|
83
|
+
for (const m of v.matches) {
|
|
84
|
+
out.push(`- ${m.name} [${m.kind}] @ ${m.file} (fan-in ${m.fan_in}, fan-out ${m.fan_out})`);
|
|
85
|
+
if (m.callers.length)
|
|
86
|
+
out.push(` called by: ${m.callers.join("; ")}`);
|
|
87
|
+
if (m.callees.length)
|
|
88
|
+
out.push(` reaches: ${m.callees.join("; ")}`);
|
|
89
|
+
}
|
|
90
|
+
return out.join(NL);
|
|
91
|
+
}
|
|
46
92
|
//# sourceMappingURL=format.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -19,7 +19,7 @@ import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
|
19
19
|
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, pullHunch } from "../extractors/git.js";
|
|
20
20
|
import { flushCapture } from "../integrations/sync.js";
|
|
21
21
|
import { ensureTeamOverlay } from "../integrations/team.js";
|
|
22
|
-
import { formatContext } from "../core/format.js";
|
|
22
|
+
import { formatContext, formatStructure } from "../core/format.js";
|
|
23
23
|
import { compareCandidates } from "../core/compare.js";
|
|
24
24
|
import { checkConformance } from "../core/conformance.js";
|
|
25
25
|
import { renderMarkdown, renderImpact, verdict } from "../core/checkreport.js";
|
|
@@ -528,6 +528,14 @@ export function buildServer(root) {
|
|
|
528
528
|
return err(`Failed to compute merge verdict: ${e.message}`);
|
|
529
529
|
}
|
|
530
530
|
});
|
|
531
|
+
// -- hunch_structure (graph-served orientation — the anti-grep) ------------
|
|
532
|
+
server.registerTool("hunch_structure", {
|
|
533
|
+
title: "The indexed shape of the repo / a dir / a file / a symbol",
|
|
534
|
+
description: "Orient WITHOUT grep/glob rounds: the graph already holds the repo's structure. No target → repo map (components + directories by symbol weight). A directory → its files with their symbols. A file → its outline (symbols, fan-in/out, callers). An exact symbol name → its definition site(s) with one-hop neighbors. Call this FIRST when exploring unfamiliar code — it tells you exactly which file to read, instead of searching for it.",
|
|
535
|
+
inputSchema: {
|
|
536
|
+
target: z.string().optional().describe("A directory, file path, or exact symbol name. Omit for the repo map."),
|
|
537
|
+
},
|
|
538
|
+
}, async ({ target }) => ok(formatStructure(store.structure(target))));
|
|
531
539
|
// -- hunch_pr_impact (read-only impact surface — advisory, never gates) ----
|
|
532
540
|
server.registerTool("hunch_pr_impact", {
|
|
533
541
|
title: "PR impact: the dependency + memory surface of a change",
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -705,6 +705,70 @@ export class HunchStore {
|
|
|
705
705
|
decisions: [...decisions.values()],
|
|
706
706
|
};
|
|
707
707
|
}
|
|
708
|
+
/** Structure view (hunch_structure / hunch structure): serve the indexed shape of
|
|
709
|
+
* the repo so an agent ORIENTS from the graph instead of running grep/glob rounds.
|
|
710
|
+
* Resolution: no target -> repo map; a directory -> its files+symbols; a file ->
|
|
711
|
+
* its outline; a symbol name -> exact definition site(s) with one-hop neighbors.
|
|
712
|
+
* Deterministic, read-only, straight from the derived index. */
|
|
713
|
+
structure(target) {
|
|
714
|
+
if (!target || !target.trim()) {
|
|
715
|
+
const components = this.recs("components").map((c) => ({ id: c.id, name: c.name, responsibility: c.responsibility, paths: c.paths }));
|
|
716
|
+
const rows = this.db.prepare(`SELECT file, count(*) AS n FROM symbols GROUP BY file`).all();
|
|
717
|
+
const dirs = new Map();
|
|
718
|
+
for (const r of rows) {
|
|
719
|
+
const dir = r.file.includes("/") ? r.file.slice(0, r.file.lastIndexOf("/")) : ".";
|
|
720
|
+
const e = dirs.get(dir) ?? { files: 0, symbols: 0 };
|
|
721
|
+
e.files++;
|
|
722
|
+
e.symbols += r.n;
|
|
723
|
+
dirs.set(dir, e);
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
kind: "repo",
|
|
727
|
+
components,
|
|
728
|
+
dirs: [...dirs.entries()].map(([dir, v]) => ({ dir, ...v })).sort((a, b) => b.symbols - a.symbols),
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
const t = toPosixTarget(target.trim()).replace(/\/+$/, "");
|
|
732
|
+
// FILE: exact path or unique suffix
|
|
733
|
+
const fileRows = this.db.prepare(`SELECT id, name, kind, loc, fan_in, fan_out FROM symbols WHERE file = ? ORDER BY fan_in DESC, name`).all(t);
|
|
734
|
+
const fileHit = fileRows.length ? t : this.db.prepare(`SELECT DISTINCT file FROM symbols WHERE file LIKE ?`).all(`%/${t}`).map((r) => r.file);
|
|
735
|
+
const file = typeof fileHit === "string" ? fileHit : fileHit.length === 1 ? fileHit[0] : null;
|
|
736
|
+
if (file) {
|
|
737
|
+
const syms = fileRows.length ? fileRows : this.db.prepare(`SELECT id, name, kind, loc, fan_in, fan_out FROM symbols WHERE file = ? ORDER BY fan_in DESC, name`).all(file);
|
|
738
|
+
return {
|
|
739
|
+
kind: "file",
|
|
740
|
+
file,
|
|
741
|
+
symbols: syms.map((r) => ({ ...r, callers: this.edgeNeighbors(r.id, "in", 5) })),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
// DIRECTORY: any indexed file under the prefix
|
|
745
|
+
const dirFiles = this.db.prepare(`SELECT file, name, kind, fan_in FROM symbols WHERE file LIKE ? ORDER BY file, fan_in DESC`).all(`${t}/%`);
|
|
746
|
+
if (dirFiles.length) {
|
|
747
|
+
const byFile = new Map();
|
|
748
|
+
for (const r of dirFiles) {
|
|
749
|
+
const list = byFile.get(r.file) ?? [];
|
|
750
|
+
list.push({ name: r.name, kind: r.kind, fan_in: r.fan_in });
|
|
751
|
+
byFile.set(r.file, list);
|
|
752
|
+
}
|
|
753
|
+
return { kind: "dir", dir: t, files: [...byFile.entries()].map(([f, symbols]) => ({ file: f, symbols })) };
|
|
754
|
+
}
|
|
755
|
+
// SYMBOL: exact name
|
|
756
|
+
const named = this.db.prepare(`SELECT id, name, kind, file, fan_in, fan_out FROM symbols WHERE name = ? LIMIT 10`).all(t);
|
|
757
|
+
if (named.length) {
|
|
758
|
+
return {
|
|
759
|
+
kind: "symbol",
|
|
760
|
+
matches: named.map((m) => ({ ...m, callers: this.edgeNeighbors(m.id, "in", 6), callees: this.edgeNeighbors(m.id, "out", 6) })),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
return { kind: "none", target: t };
|
|
764
|
+
}
|
|
765
|
+
/** Labelled one-hop edge neighbors of a node ("in" = who reaches it, "out" = what it reaches). */
|
|
766
|
+
edgeNeighbors(id, dir, limit) {
|
|
767
|
+
const sql = dir === "in"
|
|
768
|
+
? `SELECT e."from" AS nb FROM edges e WHERE e."to" = ? AND e.type IN ('calls','depends_on','imports','contains') LIMIT ?`
|
|
769
|
+
: `SELECT e."to" AS nb FROM edges e WHERE e."from" = ? AND e.type IN ('calls','depends_on','imports','contains') LIMIT ?`;
|
|
770
|
+
return this.db.prepare(sql).all(id, limit).map((r) => this.nodeLabel(r.nb));
|
|
771
|
+
}
|
|
708
772
|
/** Constraints whose scope glob matches a path/glob (hunch_check_constraints).
|
|
709
773
|
* By default only ACTIVE invariants are returned — a retired constraint is no
|
|
710
774
|
* longer enforced. Pass `{ asOf }` to instead return the invariants in force at
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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).",
|