@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 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
- The anchor is one optional field. A decision can carry a **`topic`** the thing it's the current answer
109
- for (e.g. `"auth.session"`) and topic gives you a query contract: **current** (the one live answer),
110
- **history** (the supersede trail), and **rejected** (what was ruled out and why). It's fully
111
- backward-compatible: `topic` defaults to `null`, there's **no schema bump**, and existing graphs load
112
- unchanged.
113
-
114
- - **Read-time grounding.** The pre-edit (PreToolUse) hook now surfaces a file's topic-anchored decisions
115
- *before* the AI writes with doc-precedence framing ("follow the graph, not a stale doc") and what each
116
- decision **rejected**, so the model doesn't happily re-add the approach you already ruled out.
117
- - **`anchor-stale` drift deterministic, no guessing.** A new drift kind fires when a file is still
118
- anchored to a **superseded** decision while a **current** one exists for its topic. It shows up in
119
- `hunch doctor` and in a CI-gateable `hunch drift`:
120
-
121
- ```bash
122
- hunch drift # exits non-zero on anchor-stale drift or a topic collision (>1 live decision)
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 anchorsnever 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 across the file's whole life instead
234
- of relaxing to advisory after the file is edited again. The dep matcher reads the **parsed
235
- import**, so a comment or string naming the module can't false-positive and a submodule
236
- (`lodash/groupBy`) is still caught; a correction your assistant records gets the same matcher
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 Hunch unions it into every query and guard **locally**
267
- (MCP and the pre-edit hook see your sensitive decisions/bugs/constraints) while your public
268
- `.hunch/` stays clean. It writes a gitignored `.hunch/local.json` so it's auto-detected **no
269
- env var, no shell-profile edit** (and `HUNCH_PRIVATE_DIR` still overrides per-shell). **Opt-in,
270
- default-off** (no config → fully inert), and **leak-safe by construction**: committed files and
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
- `hunch private --repo <url> --migrate` does it in one shot: it **moves** your existing public
280
- records into the overlay (union by id nothing is lost), empties the public store, untracks +
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")
@@ -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
@@ -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",
@@ -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.1.0",
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).",