@davesheffer/hunch 1.1.1 → 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
@@ -131,6 +131,7 @@ cd your-repo
131
131
  hunch init # scaffold .hunch/, index, install hooks, wire up assistants
132
132
  hunch backfill --since 90d # cold start: seed decisions from recent git history
133
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
134
135
  ```
135
136
 
136
137
  **Claude Code users — one-step plugin install** (MCP tools + `/hunch:capture`, `/hunch:heal`, `/hunch:why`, `/hunch:fix`, `/hunch:fragile`):
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";
@@ -1843,6 +1843,21 @@ program
1843
1843
  store.close();
1844
1844
  }
1845
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
+ });
1846
1861
  // ---- impact (PR impact — read-only, advisory) ------------------------------
1847
1862
  program
1848
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.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).",