@davesheffer/hunch 1.13.1 → 1.15.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/dist/cli/index.js CHANGED
@@ -64,7 +64,7 @@ import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, st
64
64
  import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
65
65
  import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
66
66
  import { planAutoReview, planMutations } from "../core/autoreview.js";
67
- import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
67
+ import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval/harness.js";
68
68
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
69
69
  import { computeDrift } from "../core/drift.js";
70
70
  import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
@@ -1349,16 +1349,20 @@ program
1349
1349
  // Default is deterministic (FTS + graph, no model). --semantic only adds the
1350
1350
  // semantic leg when embeddings actually exist; otherwise it's still FTS + graph.
1351
1351
  const embedder = opts.semantic ? await selectEmbedder() : undefined;
1352
- const lift = await evaluateGraphLift(store, cases, { k, embedder, kind: opts.kind });
1352
+ const evalOpts = { k, embedder, kind: opts.kind };
1353
+ const off = await evaluateRetrieval(store, cases, { ...evalOpts, graphWeight: 0 });
1354
+ const traversal = await evaluateTraversalLift(store, cases, evalOpts);
1353
1355
  const pct = (x) => `${(x * 100).toFixed(1)}%`;
1354
1356
  const dpt = (x) => `${x >= 0 ? "+" : ""}${(x * 100).toFixed(1)}pt`;
1355
1357
  const dnum = (x) => `${x >= 0 ? "+" : ""}${x.toFixed(3)}`;
1356
1358
  console.log(`Eval over ${cases.length} case(s), k=${k}${opts.semantic ? " (semantic + graph + FTS)" : " (FTS + graph)"}\n`);
1357
1359
  console.log(` Recall@${k} MRR hit-rate`);
1358
- console.log(` graph OFF ${pct(lift.off.recallAtK).padStart(7)} ${lift.off.mrr.toFixed(3)} ${pct(lift.off.hitRate)}`);
1359
- console.log(` graph ON ${pct(lift.on.recallAtK).padStart(7)} ${lift.on.mrr.toFixed(3)} ${pct(lift.on.hitRate)}`);
1360
- console.log(` graph LIFT ${dpt(lift.recallDelta).padStart(7)} ${dnum(lift.mrrDelta)}`);
1361
- const misses = lift.on.perCase.filter((c) => c.found === 0);
1360
+ console.log(` graph OFF ${pct(off.recallAtK).padStart(7)} ${off.mrr.toFixed(3)} ${pct(off.hitRate)}`);
1361
+ console.log(` graph 1-HOP ${pct(traversal.oneHop.recallAtK).padStart(7)} ${traversal.oneHop.mrr.toFixed(3)} ${pct(traversal.oneHop.hitRate)}`);
1362
+ console.log(` graph BOUNDED ${pct(traversal.bounded.recallAtK).padStart(7)} ${traversal.bounded.mrr.toFixed(3)} ${pct(traversal.bounded.hitRate)}`);
1363
+ console.log(` graph LIFT ${dpt(traversal.bounded.recallAtK - off.recallAtK).padStart(7)} ${dnum(traversal.bounded.mrr - off.mrr)}`);
1364
+ console.log(` depth LIFT ${dpt(traversal.recallDelta).padStart(7)} ${dnum(traversal.mrrDelta)}`);
1365
+ const misses = traversal.bounded.perCase.filter((c) => c.found === 0);
1362
1366
  if (misses.length) {
1363
1367
  console.log(`\n ${misses.length} case(s) with no expected hit — curate or tune:`);
1364
1368
  for (const m of misses.slice(0, 10))
@@ -8,7 +8,13 @@ export async function evaluateRetrieval(store, cases, opts = {}) {
8
8
  // buries terse records before any filter.
9
9
  const hits = opts.kind
10
10
  ? await store.searchScoped(c.query, opts.kind, k, { embedder: opts.embedder })
11
- : await store.hybridSearch(c.query, k, { embedder: opts.embedder, graphWeight: opts.graphWeight });
11
+ : await store.hybridSearch(c.query, k, {
12
+ embedder: opts.embedder,
13
+ graphWeight: opts.graphWeight,
14
+ graphDepth: opts.graphDepth,
15
+ graphNodeCap: opts.graphNodeCap,
16
+ graphTokenCap: opts.graphTokenCap,
17
+ });
12
18
  const top = hits.slice(0, k).map((h) => h.ref);
13
19
  const expected = new Set(c.expected);
14
20
  let found = 0;
@@ -46,6 +52,17 @@ export async function evaluateGraphLift(store, cases, opts = {}) {
46
52
  const on = await evaluateRetrieval(store, cases, opts);
47
53
  return { off, on, recallDelta: on.recallAtK - off.recallAtK, mrrDelta: on.mrr - off.mrr };
48
54
  }
55
+ /** Compare the historical 1-hop graph with the shipped bounded traversal. */
56
+ export async function evaluateTraversalLift(store, cases, opts = {}) {
57
+ const oneHop = await evaluateRetrieval(store, cases, { ...opts, graphDepth: 1 });
58
+ const bounded = await evaluateRetrieval(store, cases, opts);
59
+ return {
60
+ oneHop,
61
+ bounded,
62
+ recallDelta: bounded.recallAtK - oneHop.recallAtK,
63
+ mrrDelta: bounded.mrr - oneHop.mrr,
64
+ };
65
+ }
49
66
  /** Parse + validate a golden-set JSON string (array of {query, expected[]}). */
50
67
  export function loadGoldenSet(raw) {
51
68
  const data = JSON.parse(raw);
@@ -8,7 +8,8 @@
8
8
  * `indexRepo` persists that exact scan into the JSON source of truth; its caller
9
9
  * then runs HunchStore.reindex() to refresh the SQLite index.
10
10
  */
11
- import { dirname, posix } from "node:path";
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join, posix } from "node:path";
12
13
  import { parseSource, attributeCalls } from "./parse.js";
13
14
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
14
15
  import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
@@ -135,9 +136,15 @@ export function scanRepo(store, root, opts = {}) {
135
136
  // JS/TS resolver and look unimported.
136
137
  const hasSrcLayout = [...fileSymbols.keys()].some((f) => f.startsWith("src/"));
137
138
  const pyRoots = hasSrcLayout ? ["", "src"] : [""];
138
- const resolveImportTarget = (file, spec) => languageFor(file)?.id === "python"
139
- ? resolvePythonImport(file, spec, fileSymbols, pyRoots)
140
- : resolveImport(file, spec, fileSymbols);
139
+ const goModule = readGoModulePath(root);
140
+ const resolveImportTarget = (file, spec) => {
141
+ const langId = languageFor(file)?.id;
142
+ if (langId === "python")
143
+ return resolvePythonImport(file, spec, fileSymbols, pyRoots);
144
+ if (langId === "go")
145
+ return resolveGoImport(spec, fileSymbols, goModule);
146
+ return resolveImport(file, spec, fileSymbols);
147
+ };
141
148
  const importedFiles = new Map(perFileImports.map(({ file, imports }) => [
142
149
  file,
143
150
  new Set(imports.map((specifier) => resolveImportTarget(file, specifier)).filter((target) => !!target)),
@@ -353,6 +360,48 @@ function resolvePythonImport(fromFile, spec, fileSymbols, pyRoots) {
353
360
  const modulePath = baseDir ? `${baseDir}/${tailPath}` : tailPath;
354
361
  return firstExistingPyModule(modulePath, fileSymbols);
355
362
  }
363
+ /** The `module` path declared in the repo's go.mod, or null. A resolution HINT
364
+ * only (it widens depends_on edge coverage); reading it best-effort from the
365
+ * filesystem never gates a scan. */
366
+ function readGoModulePath(root) {
367
+ try {
368
+ const match = /^module\s+(\S+)/m.exec(readFileSync(join(root, "go.mod"), "utf8"));
369
+ return match ? match[1] : null;
370
+ }
371
+ catch {
372
+ return null;
373
+ }
374
+ }
375
+ /** Lexicographically-first tracked .go file whose directory is exactly `dir`
376
+ * ("" = repo root) — a Go import names a PACKAGE (directory), so any file in it
377
+ * identifies the right component for a depends_on edge. */
378
+ function firstGoFileInDir(dir, fileSymbols) {
379
+ let best = null;
380
+ for (const f of fileSymbols.keys()) {
381
+ if (!f.endsWith(".go"))
382
+ continue;
383
+ const d = toPosix(dirname(f));
384
+ const matches = dir === "" ? d === "." : d === dir;
385
+ if (matches && (!best || f < best))
386
+ best = f;
387
+ }
388
+ return best;
389
+ }
390
+ /** Resolve a Go import path to a tracked file. Sibling to resolvePythonImport():
391
+ * an in-module import is the go.mod module path plus the package directory, so
392
+ * strip the declared module prefix and look the directory up exactly; with no
393
+ * go.mod, try the path as a repo-relative directory. Anything else (stdlib,
394
+ * external modules) resolves to null — no suffix guessing, a wrong depends_on
395
+ * edge is worse than a missing one. */
396
+ function resolveGoImport(spec, fileSymbols, goModule) {
397
+ if (goModule) {
398
+ if (spec === goModule)
399
+ return firstGoFileInDir("", fileSymbols);
400
+ if (spec.startsWith(`${goModule}/`))
401
+ return firstGoFileInDir(spec.slice(goModule.length + 1), fileSymbols);
402
+ }
403
+ return firstGoFileInDir(spec, fileSymbols);
404
+ }
356
405
  /** Derive components from the directory layout: the directory immediately under
357
406
  * `src/` (or the top-level dir) groups files into a module component. */
358
407
  function deriveComponents(symbols) {
@@ -125,7 +125,55 @@ const PYTHON = {
125
125
  nameToDef: { "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def" },
126
126
  builtinMethods: PY_BUILTIN_METHODS,
127
127
  };
128
- export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON];
128
+ const GO_QUERY = `
129
+ (function_declaration name: (identifier) @fn.name) @fn.def
130
+ (method_declaration name: (field_identifier) @method.name) @method.def
131
+ ;; Specific type_spec shapes FIRST: parse.ts keeps the first classification a
132
+ ;; node id receives (same mechanism the Python class patterns rely on), so a
133
+ ;; struct/interface matches its specific pattern before the generic @type.def.
134
+ (type_spec name: (type_identifier) @struct.name type: (struct_type)) @struct.def
135
+ (type_spec name: (type_identifier) @iface.name type: (interface_type)) @iface.def
136
+ (type_spec name: (type_identifier) @type.name) @type.def
137
+ ;; \`type X = Y\` is a distinct type_alias node, not a type_spec.
138
+ (type_alias name: (type_identifier) @type.name) @type.def
139
+ (import_spec path: [(interpreted_string_literal) (raw_string_literal)] @import.src)
140
+ (call_expression function: (identifier) @call.id)
141
+ (call_expression function: (selector_expression field: (field_identifier) @call.member))
142
+ `;
143
+ /** In Go every package-qualified call (fmt.Println, strings.Split, t.Errorf) is a
144
+ * selector_expression and therefore lands in @call.member — the DOMINANT call form.
145
+ * This allowlist filters the highest-frequency stdlib/testing method+function names
146
+ * so they never create false edges to same-named repo symbols; repo-specific member
147
+ * calls (s.Run(), h.Handle()) pass through and resolve conservatively like TS/PY. */
148
+ const GO_BUILTIN_METHODS = new Set([
149
+ "Error", "String", "Read", "Write", "Close", "Len", "Cap", "Reset", "Bytes", "Text",
150
+ "Scan", "Next", "Err", "Lock", "Unlock", "RLock", "RUnlock", "Done", "Add", "Wait",
151
+ "Print", "Printf", "Println", "Sprintf", "Fprintf", "Errorf", "Fatal", "Fatalf", "Fatalln",
152
+ "Log", "Logf", "Helper", "Run", "Parallel", "Skip", "Skipf", "Cleanup",
153
+ "Get", "Set", "Delete", "Load", "Store", "Range", "Value", "Context", "Deadline",
154
+ "Marshal", "Unmarshal", "Encode", "Decode", "Parse", "Format", "Sub", "Before", "After",
155
+ "Join", "Split", "Contains", "Replace", "ReplaceAll", "TrimSpace", "ToLower", "ToUpper",
156
+ "HasPrefix", "HasSuffix", "WriteString", "ReadString", "ReadAll", "Copy", "New", "Now",
157
+ "Since", "Sleep", "Unix", "Exec", "Query", "QueryRow", "Begin", "Commit", "Rollback",
158
+ ]);
159
+ const GO = {
160
+ id: "go",
161
+ extensions: [".go"],
162
+ grammarKey: "go",
163
+ loadGrammar: () => loadNativeTreeSitter().go,
164
+ query: GO_QUERY,
165
+ defNodeTypes: new Set(["function_declaration", "method_declaration", "type_spec", "type_alias"]),
166
+ defKindOf: {
167
+ "fn.def": "function", "method.def": "method", "struct.def": "class",
168
+ "iface.def": "interface", "type.def": "type",
169
+ },
170
+ nameToDef: {
171
+ "fn.name": "fn.def", "method.name": "method.def", "struct.name": "struct.def",
172
+ "iface.name": "iface.def", "type.name": "type.def",
173
+ },
174
+ builtinMethods: GO_BUILTIN_METHODS,
175
+ };
176
+ export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON, GO];
129
177
  export const CODE_EXTENSIONS = [...new Set(LANGUAGES.flatMap((l) => l.extensions))];
130
178
  export function languageFor(file) {
131
179
  for (const lang of LANGUAGES) {
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  const runtimeRequire = createRequire(import.meta.url);
6
6
  const COPY_PREFIX = "hunch-tree-sitter-";
7
- const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python"];
7
+ const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python", "tree-sitter-go"];
8
8
  let runtime = null;
9
9
  function processIsAlive(pid) {
10
10
  if (pid === process.pid)
@@ -69,7 +69,7 @@ export function loadNativeTreeSitter() {
69
69
  // underscores (tree_sitter_runtime_binding.node, tree_sitter_python_binding.node,
70
70
  // …). Missing the underscore names let an already-loaded source-built addon slip
71
71
  // past this guard and defeat the file-lock isolation entirely (issue #52).
72
- const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
72
+ const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python|-go)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
73
73
  && !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
74
74
  if (preloaded.length) {
75
75
  throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
@@ -87,7 +87,8 @@ export function loadNativeTreeSitter() {
87
87
  const Parser = runtimeRequire("tree-sitter");
88
88
  const languages = runtimeRequire("tree-sitter-typescript");
89
89
  const python = runtimeRequire("tree-sitter-python");
90
- runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python };
90
+ const go = runtimeRequire("tree-sitter-go");
91
+ runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python, go };
91
92
  }
92
93
  catch (error) {
93
94
  try {
@@ -24,6 +24,7 @@ export function stripManagedSection(text) {
24
24
  export function renderHunchSection(store, root) {
25
25
  const constraints = store.json
26
26
  .loadAll("constraints")
27
+ .filter((c) => c.status === "active" && !c.valid_to)
27
28
  .sort((a, b) => sev(b.severity) - sev(a.severity))
28
29
  .slice(0, 8);
29
30
  const counts = {
@@ -18,7 +18,7 @@ import { decisionId, findingId } from "../core/ids.js";
18
18
  import { buildCorrectionConstraint } from "../core/correction.js";
19
19
  import { knownRepoDeps } from "../synthesis/tripwires.js";
20
20
  import { refreshExistingGrounding } from "../integrations/providers.js";
21
- import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl } from "../extractors/git.js";
21
+ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
22
22
  import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
23
23
  import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
24
24
  import { formatStructure } from "../core/format.js";
@@ -43,6 +43,23 @@ import { existsSync } from "node:fs";
43
43
  import { join } from "node:path";
44
44
  const ok = (text) => ({ content: [{ type: "text", text }] });
45
45
  const err = (text) => ({ content: [{ type: "text", text }], isError: true });
46
+ /** Shared by every auto-committing write tool (issue #20): the MCP `roots` protocol
47
+ * cannot see an agent-driven `cd`/EnterWorktree, so a stdio server's cached root
48
+ * never moves on its own — this is the client-agnostic fallback, resolved fresh on
49
+ * every call by the generic tool wrapper below (see extractCwdHint). */
50
+ const cwdHintField = z.string().optional().describe("Your ACTUAL current working directory for THIS call. Pass it whenever it differs from where this MCP " +
51
+ "session started — most commonly after entering a git worktree (EnterWorktree) or `cd`-ing to a different " +
52
+ "checkout — so the write commits to that repo/branch instead of silently landing on the server's original " +
53
+ "root. Omit only when you are still in the session's starting directory.");
54
+ /** Pull `cwd` out of a tool call's already-parsed input without assuming any one
55
+ * tool's exact input shape — every write tool spreads the same cwdHintField in,
56
+ * but the wrapper below runs for every tool, read or write. */
57
+ function extractCwdHint(input) {
58
+ if (!input || typeof input !== "object")
59
+ return undefined;
60
+ const cwd = input.cwd;
61
+ return typeof cwd === "string" && cwd.trim() ? cwd : undefined;
62
+ }
46
63
  /** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
47
64
  * commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
48
65
  * next flush sweeps it up; claiming "auto-committed" there would be a lie. */
@@ -73,6 +90,21 @@ const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
73
90
  return risk;
74
91
  return "\nℹ Landed in the COMMITTED PUBLIC store (publishes with the repo). For sensitive/strategy content, re-record with private:true — the overlay store." + risk;
75
92
  };
93
+ /** Self-diagnosing destination report (issue #17/#20): the exact failure mode this
94
+ * guards against is silent — a capture landing in the wrong repo/branch with no
95
+ * sign of it short of a manual `git log` audit. Every auto-committing write tool
96
+ * appends this so the destination is always visible in the response, whether or
97
+ * not a cwd hint was involved in choosing it. */
98
+ const destinationNote = (destRoot) => {
99
+ const branch = currentBranch(destRoot);
100
+ return ` [captured${branch ? ` on branch ${branch}` : ""} in ${destRoot}]`;
101
+ };
102
+ /** Where a capture keyed to `home` actually lands: the private overlay directory when
103
+ * one is configured, else the public repo root. Centralizes the branch used at every
104
+ * destination-reporting call site below — `hunch_policy_upgrade_correction` once
105
+ * diverged from this (computing its own `artifactHome` but reporting the public root
106
+ * regardless), silently misreporting the destination for a private-homed proof. */
107
+ const resolveDestRoot = (home, store, root) => home === "private" && store.privateDir ? store.privateDir : root;
76
108
  // Read-side token budgets: every tool result is injected into a Claude Code
77
109
  // session, so an uncapped list pollutes the context window. Cap each list to its
78
110
  // highest-signal head (records are pre-sorted by severity/confidence) and tell the
@@ -435,6 +467,30 @@ export function buildServerWithRootControl(initialRoot) {
435
467
  };
436
468
  const registerTool = server.registerTool.bind(server);
437
469
  server.registerTool = ((name, config, callback) => registerTool(name, config, async (...args) => {
470
+ // Claude Code CLI never advertises `roots`/`roots/list_changed` for an agent-driven
471
+ // `cd` or EnterWorktree (issue #20) — the cached `root` above just never moves, so a
472
+ // write silently lands wherever the process was spawned. Write tools accept an
473
+ // optional `cwd` argument (see cwdHintField) as a client-agnostic fallback: resolved
474
+ // fresh on EVERY call instead of trusted from a cache, and re-homing this stdio
475
+ // process the same way a `roots` notification would. Only safe when this is the
476
+ // sole in-flight request — re-homing under a concurrent request would tear its
477
+ // root/store out from under it, so that case is refused rather than risked.
478
+ const cwdHint = extractCwdHint(args[0]);
479
+ if (cwdHint !== undefined) {
480
+ const target = canonicalRootPath(findRoot(cwdHint));
481
+ if (target !== canonicalRootPath(root)) {
482
+ if (activeRequests) {
483
+ return err(`Hunch is mid-request against ${root} and cannot safely switch to the working directory you passed ` +
484
+ `(resolves to ${target}) while another call is in flight. Retry this call once the other one completes.`);
485
+ }
486
+ try {
487
+ setRoot(cwdHint);
488
+ }
489
+ catch (error) {
490
+ return err(`Failed to switch Hunch to your working directory (${cwdHint}): ${error.message}`);
491
+ }
492
+ }
493
+ }
438
494
  activeRequests++;
439
495
  try {
440
496
  // Routing is live state, not a startup constant. A branch switch or
@@ -857,6 +913,7 @@ export function buildServerWithRootControl(initialRoot) {
857
913
  private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — for sensitive decisions kept out of a public repo. Errors if no private store is configured."),
858
914
  }),
859
915
  capture_token: z.string().optional().describe("token from hunch_capture_decision — proves this write is the tail of a grilling interview. Omit only for a quick manual record (a deprecation nudge is returned)."),
916
+ cwd: cwdHintField,
860
917
  },
861
918
  }, async ({ decision, capture_token }) => {
862
919
  try {
@@ -1029,7 +1086,8 @@ export function buildServerWithRootControl(initialRoot) {
1029
1086
  const where = decision.private
1030
1087
  ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
1031
1088
  : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
1032
- return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}${captureNote}${quality}`);
1089
+ const dest = destinationNote(resolveDestRoot(home, store, root));
1090
+ return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${dest}${supNote}${note}${captureNote}${quality}`);
1033
1091
  }
1034
1092
  catch (e) {
1035
1093
  return err(`Failed to record decision: ${e.message}`);
@@ -1049,6 +1107,7 @@ export function buildServerWithRootControl(initialRoot) {
1049
1107
  source_decision: z.string().optional().describe("id of a decision this correction derives from."),
1050
1108
  private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — a sensitive rule enforced locally (pre-edit hook + local check) but never exposed in a public PR comment. Errors if no private store is configured."),
1051
1109
  capture_token: z.string().optional().describe("token from hunch_capture_decision. The rule is recorded and enforced either way — the token only decides whether it may DENY: without one it lands as advisory testimony capped at severity 'warning'."),
1110
+ cwd: cwdHintField,
1052
1111
  },
1053
1112
  }, async (input) => {
1054
1113
  try {
@@ -1103,7 +1162,8 @@ export function buildServerWithRootControl(initialRoot) {
1103
1162
  : `
1104
1163
 
1105
1164
  ⚠ Recorded WITHOUT a capture interview — this rule is agent_recorded TESTIMONY${input.severity === "blocking" ? ' and was capped from "blocking" to "warning"' : ""}. It IS enforced: the pre-edit hook and CI surface it on every matching edit from now on. What it cannot do is DENY an edit — only a rule a human countersigned may block. Countersign it by re-recording through hunch_capture_decision → hunch_record_correction(capture_token).`;
1106
- return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.${reviewNote}${tierNote}`);
1165
+ const dest = destinationNote(resolveDestRoot(home, store, root));
1166
+ return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where}${dest} It now ${enforce}.${reviewNote}${tierNote}`);
1107
1167
  }
1108
1168
  catch (e) {
1109
1169
  return err(`Failed to record correction: ${e.message}`);
@@ -1128,6 +1188,7 @@ export function buildServerWithRootControl(initialRoot) {
1128
1188
  resolved_commit: z.string().optional().describe("the commit that fixed it (with triage:'resolved')"),
1129
1189
  private: z.boolean().optional().describe("write into the PRIVATE overlay store instead of the committed repo. Errors if no private store is configured."),
1130
1190
  }),
1191
+ cwd: cwdHintField,
1131
1192
  },
1132
1193
  }, async ({ finding }) => {
1133
1194
  try {
@@ -1172,7 +1233,8 @@ export function buildServerWithRootControl(initialRoot) {
1172
1233
  ? `\n\n△ violates_constraint ${rec.violates_constraint} resolves to no known constraint — if the rule isn't recorded yet, hunch_record_correction it and re-record this finding with the real id.`
1173
1234
  : "";
1174
1235
  const noEvidence = rec.evidence.length ? "" : "\n\n△ No evidence attached — a finding without the query/output that produced it is an opinion. Re-record with evidence when you have it.";
1175
- return ok(`${existing ? "Updated" : "Recorded"} finding ${id}: "${rec.title}" (${rec.triage}/${rec.severity}, observed ${rec.observed_at.slice(0, 10)}).${where} It now grounds edits to: ${[...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)"}.${danglingCon}${noEvidence}`);
1236
+ const dest = destinationNote(resolveDestRoot(home, store, root));
1237
+ return ok(`${existing ? "Updated" : "Recorded"} finding ${id}: "${rec.title}" (${rec.triage}/${rec.severity}, observed ${rec.observed_at.slice(0, 10)}).${where}${dest} It now grounds edits to: ${[...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)"}.${danglingCon}${noEvidence}`);
1176
1238
  }
1177
1239
  catch (e) {
1178
1240
  return err(`Failed to record finding: ${e.message}`);
@@ -1207,6 +1269,7 @@ export function buildServerWithRootControl(initialRoot) {
1207
1269
  public_only: z.boolean().optional().describe("Read and write only the public correction home."),
1208
1270
  private_only: z.boolean().optional().describe("Keep correction-derived evidence/policy/proof artifacts in the configured private overlay; the public source-code graph is refreshed before proof."),
1209
1271
  include_artifacts: z.boolean().optional().describe("Include the complete Policy IR, proof plan, proof receipts, and evidence object. Default output is a concise review envelope."),
1272
+ cwd: cwdHintField,
1210
1273
  },
1211
1274
  }, async ({ constraint_id, public_only, private_only, include_artifacts }) => {
1212
1275
  try {
@@ -1244,8 +1307,10 @@ export function buildServerWithRootControl(initialRoot) {
1244
1307
  if (artifactHome === "private") {
1245
1308
  flushMemoryHome(store, hunchPaths(root).hunch, "private", `hunch: prove correction ${constraint_id}`, startupTeamRoute ?? undefined);
1246
1309
  }
1310
+ const destRoot = resolveDestRoot(artifactHome, store, root);
1311
+ const destination = { root: destRoot, branch: currentBranch(destRoot) };
1247
1312
  if (include_artifacts)
1248
- return ok(JSON.stringify(upgrade, null, 2));
1313
+ return ok(JSON.stringify({ ...upgrade, destination }, null, 2));
1249
1314
  return ok(JSON.stringify({
1250
1315
  status: upgrade.status,
1251
1316
  correction_id: upgrade.correction_id,
@@ -1258,6 +1323,7 @@ export function buildServerWithRootControl(initialRoot) {
1258
1323
  authority: upgrade.authority,
1259
1324
  effects: upgrade.effects,
1260
1325
  activation: upgrade.activation,
1326
+ destination,
1261
1327
  }, null, 2));
1262
1328
  }
1263
1329
  catch (e) {
@@ -1416,6 +1482,7 @@ export function buildServerWithRootControl(initialRoot) {
1416
1482
  inputSchema: {
1417
1483
  policy_id: z.string().describe("Policy id (pol_*)."),
1418
1484
  public_only: z.boolean().optional().describe("Exclude private-overlay policy and evidence records."),
1485
+ cwd: cwdHintField,
1419
1486
  },
1420
1487
  }, async ({ policy_id, public_only }) => {
1421
1488
  try {
@@ -1425,7 +1492,8 @@ export function buildServerWithRootControl(initialRoot) {
1425
1492
  if (!home)
1426
1493
  throw new Error(`policy ${policy_id} has no exact storage home`);
1427
1494
  flushMemoryHome(store, hunchPaths(root).hunch, home, `hunch: plan policy ${policy_id}`, startupTeamRoute ?? undefined);
1428
- return ok(JSON.stringify(plan, null, 2));
1495
+ const destRoot = resolveDestRoot(home, store, root);
1496
+ return ok(JSON.stringify({ ...plan, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
1429
1497
  }
1430
1498
  catch (e) {
1431
1499
  return err(`Failed to generate policy proof plan: ${e.message}`);
@@ -1692,6 +1760,7 @@ export function buildServerWithRootControl(initialRoot) {
1692
1760
  limit: z.number().int().min(1).max(100).optional().describe("Item limit used by the exact review packet (default 30)."),
1693
1761
  allow_install_scripts: z.array(z.string().min(1).max(214)).max(20).optional().describe("Exact dependency package names allowed to run lifecycle scripts while provisioning snapshots."),
1694
1762
  dependency_timeout_ms: z.number().int().min(1).max(900000).optional().describe("Timeout for each exact dependency snapshot operation (default 300000ms)."),
1763
+ cwd: cwdHintField,
1695
1764
  },
1696
1765
  }, async ({ decision_id, since, max_commits, limit, allow_install_scripts, dependency_timeout_ms }) => {
1697
1766
  try {
@@ -1704,7 +1773,8 @@ export function buildServerWithRootControl(initialRoot) {
1704
1773
  dependencyTimeoutMs: dependency_timeout_ms ?? 300_000,
1705
1774
  });
1706
1775
  flushMemoryHome(store, hunchPaths(root).hunch, "private", "hunch: materialize G2 behavior policies", startupTeamRoute ?? undefined);
1707
- return ok(JSON.stringify(materialized, null, 2));
1776
+ const destRoot = resolveDestRoot("private", store, root);
1777
+ return ok(JSON.stringify({ ...materialized, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
1708
1778
  }
1709
1779
  catch (e) {
1710
1780
  return err(`Failed to materialize G2 behavior policies: ${e.message}`);
@@ -662,7 +662,11 @@ export class HunchStore {
662
662
  // The graph stream is model-free, so it contributes even on a lean (no-embeddings)
663
663
  // install. With neither semantic nor graph signal, return pure FTS so the
664
664
  // zero-fusion-overhead fast path is preserved.
665
- const graph = this.graphExpand([...fts, ...sem], 50, gw);
665
+ const graph = this.graphExpand([...fts, ...sem], {
666
+ maxDepth: boundedWhole(opts.graphDepth, GRAPH_MAX_DEPTH, GRAPH_DEPTH_HARD_MAX),
667
+ nodeCap: boundedWhole(opts.graphNodeCap, GRAPH_NODE_CAP, GRAPH_NODE_HARD_MAX),
668
+ tokenCap: boundedWhole(opts.graphTokenCap, GRAPH_TOKEN_CAP, GRAPH_TOKEN_HARD_MAX),
669
+ }, gw);
666
670
  if (!sem.length && !graph.length)
667
671
  return this.rerankByPriors(fts, limit, query);
668
672
  // Fuse with headroom so the prior rerank can promote from below the cut line.
@@ -764,47 +768,91 @@ export class HunchStore {
764
768
  add(graph, graphWeight);
765
769
  return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, limit).map((e) => ({ ...e.hit, score: e.score }));
766
770
  }
767
- /** Graph retrieval stream (roadmap #1): 1-hop expansion over the dependency graph
768
- * from the lexical/semantic seed hits. For each seed SYMBOL, surface its direct
769
- * neighbors (callers/callees, importers/imported, container) the cross-file
770
- * evidence a "why" question needs but that neither bm25 nor cosine reaches. Each
771
- * neighbor accrues GAMMA-decayed support per linking seed (one pulled in by several
772
- * top seeds ranks higher); seeds themselves are excluded, so this only ADDS context.
773
- * Deterministic, model-free (runs on a lean install too), one indexed query per seed. */
774
- graphExpand(seeds, n, weight = RRF_W_GRAPH) {
775
- if (weight <= 0)
771
+ /** Bounded relevance traversal over the dependency graph. Lexical/semantic symbol
772
+ * and component hits seed a small number of depth layers; support decays per hop
773
+ * and adds across multiple useful paths. Each frontier and the returned context
774
+ * obey a hard node cap, while hydration obeys a separate token cap. Only records
775
+ * present in the indexed symbol/component tables can enter the frontier, so
776
+ * shared external-package hubs never become context or bridge unrelated symbols. */
777
+ graphExpand(seeds, opts, weight = RRF_W_GRAPH) {
778
+ if (weight <= 0 || GRAPH_GAMMA <= 0 || opts.maxDepth <= 0 || opts.nodeCap <= 0 || opts.tokenCap <= 0)
776
779
  return [];
777
- const symSeeds = seeds.filter((h) => h.ref.startsWith("sym_"));
778
- if (!symSeeds.length)
780
+ const seedRefs = new Set(seeds.map((h) => h.ref)); // never re-surface a seed
781
+ let frontier = new Map();
782
+ seeds.forEach((hit, rank) => {
783
+ if (!isGraphContextRef(hit.ref))
784
+ return;
785
+ frontier.set(hit.ref, (frontier.get(hit.ref) ?? 0) + 1 / (RRF_K + rank + 1));
786
+ });
787
+ if (!frontier.size)
779
788
  return [];
780
- const seen = new Set(seeds.map((h) => h.ref)); // never re-surface a seed
781
789
  const nbStmt = this.db.prepare(
782
790
  /* sql */ `
783
- SELECT e."to" AS nb FROM edges e WHERE e."from" = ? AND e.type IN ('calls','depends_on','imports','contains')
791
+ SELECT e."to" AS nb
792
+ FROM edges e
793
+ WHERE e."from" = ? AND e.type IN ('calls','depends_on','imports','contains')
794
+ AND (EXISTS (SELECT 1 FROM symbols s WHERE s.id = e."to")
795
+ OR EXISTS (SELECT 1 FROM components c WHERE c.id = e."to"))
784
796
  UNION
785
- SELECT e."from" AS nb FROM edges e WHERE e."to" = ? AND e.type IN ('calls','depends_on','imports','contains')`);
797
+ SELECT e."from" AS nb
798
+ FROM edges e
799
+ WHERE e."to" = ? AND e.type IN ('calls','depends_on','imports','contains')
800
+ AND (EXISTS (SELECT 1 FROM symbols s WHERE s.id = e."from")
801
+ OR EXISTS (SELECT 1 FROM components c WHERE c.id = e."from"))
802
+ ORDER BY nb`);
803
+ const expanded = new Set();
786
804
  const score = new Map();
787
- symSeeds.forEach((h, i) => {
788
- const contrib = GRAPH_GAMMA / (RRF_K + i + 1);
789
- for (const r of nbStmt.all(h.ref, h.ref)) {
790
- if (seen.has(r.nb))
791
- continue;
792
- score.set(r.nb, (score.get(r.nb) ?? 0) + contrib);
805
+ for (let depth = 1; depth <= opts.maxDepth && frontier.size; depth++) {
806
+ const layer = new Map();
807
+ const rankedFrontier = [...frontier.entries()]
808
+ .sort((a, b) => b[1] - a[1] || compareRefs(a[0], b[0]))
809
+ .slice(0, opts.nodeCap)
810
+ .filter(([ref]) => !expanded.has(ref));
811
+ // Mark the whole frontier visited before walking it. Otherwise an edge
812
+ // between peers in this layer credits whichever peer sorts second as if
813
+ // it were deeper context, making scores depend on ref ordering.
814
+ for (const [ref] of rankedFrontier)
815
+ expanded.add(ref);
816
+ for (const [ref, support] of rankedFrontier) {
817
+ const contribution = support * GRAPH_GAMMA;
818
+ for (const row of nbStmt.all(ref, ref)) {
819
+ if (seedRefs.has(row.nb) || expanded.has(row.nb))
820
+ continue;
821
+ layer.set(row.nb, (layer.get(row.nb) ?? 0) + contribution);
822
+ }
793
823
  }
794
- });
824
+ const rankedLayer = [...layer.entries()]
825
+ .sort((a, b) => b[1] - a[1] || compareRefs(a[0], b[0]))
826
+ .slice(0, opts.nodeCap);
827
+ for (const [ref, support] of rankedLayer)
828
+ score.set(ref, (score.get(ref) ?? 0) + support);
829
+ frontier = new Map(rankedLayer);
830
+ }
795
831
  if (!score.size)
796
832
  return [];
797
- const top = [...score.entries()].sort((a, b) => b[1] - a[1]).slice(0, n);
833
+ const top = [...score.entries()]
834
+ .sort((a, b) => b[1] - a[1] || compareRefs(a[0], b[0]))
835
+ .slice(0, opts.nodeCap);
798
836
  // Hydrate title/snippet from the FTS table in ONE query (mirrors cosineRank).
799
837
  const placeholders = top.map(() => "?").join(",");
800
838
  const meta = new Map();
801
- for (const row of this.db.prepare(`SELECT ref, title, body FROM search WHERE ref IN (${placeholders})`).all(...top.map(([ref]) => ref))) {
802
- meta.set(row.ref, { title: row.title, body: row.body });
839
+ for (const row of this.db.prepare(`SELECT ref, kind, title, body FROM search WHERE ref IN (${placeholders})`).all(...top.map(([ref]) => ref))) {
840
+ meta.set(row.ref, { kind: row.kind, title: row.title, body: row.body });
803
841
  }
804
- return top.map(([ref, s]) => {
842
+ const hits = [];
843
+ let usedTokens = 0;
844
+ for (const [ref, s] of top) {
805
845
  const m = meta.get(ref);
806
- return { ref, kind: ref.startsWith("cmp_") ? "component" : "symbol", title: m?.title ?? ref, snippet: (m?.body ?? "").slice(0, 120), score: s };
807
- });
846
+ if (!m)
847
+ continue;
848
+ const hit = { ref, kind: m.kind, title: m.title, snippet: m.body.slice(0, 120), score: s };
849
+ const tokenCost = estimatedSearchHitTokens(hit);
850
+ if (usedTokens + tokenCost > opts.tokenCap)
851
+ continue;
852
+ hits.push(hit);
853
+ usedTokens += tokenCost;
854
+ }
855
+ return hits;
808
856
  }
809
857
  /** All decisions/bugs/constraints/symbols/components touching a file path or
810
858
  * symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
@@ -1581,7 +1629,13 @@ const RRF_K = numEnv("HUNCH_RRF_K", 60);
1581
1629
  const RRF_W_FTS = numEnv("HUNCH_RRF_W_FTS", 1);
1582
1630
  const RRF_W_SEM = numEnv("HUNCH_RRF_W_SEM", 0.7);
1583
1631
  const RRF_W_GRAPH = numEnv("HUNCH_RRF_W_GRAPH", 0.5);
1584
- const GRAPH_GAMMA = numEnv("HUNCH_GRAPH_GAMMA", 0.25);
1632
+ const GRAPH_GAMMA = Math.min(1, numEnv("HUNCH_GRAPH_GAMMA", 0.25));
1633
+ const GRAPH_DEPTH_HARD_MAX = 8;
1634
+ const GRAPH_NODE_HARD_MAX = 500;
1635
+ const GRAPH_TOKEN_HARD_MAX = 100_000;
1636
+ const GRAPH_MAX_DEPTH = boundedWhole(numEnv("HUNCH_GRAPH_MAX_DEPTH", 2), 2, GRAPH_DEPTH_HARD_MAX);
1637
+ const GRAPH_NODE_CAP = boundedWhole(numEnv("HUNCH_GRAPH_NODE_CAP", 50), 50, GRAPH_NODE_HARD_MAX);
1638
+ const GRAPH_TOKEN_CAP = boundedWhole(numEnv("HUNCH_GRAPH_TOKEN_CAP", 2_000), 2_000, GRAPH_TOKEN_HARD_MAX);
1585
1639
  /** Prior tuning: how far a trust weight may move a hit from its FUSED position.
1586
1640
  * SCALE maps the weight's realistic span onto positions (this repo's own decisions
1587
1641
  * span w 0.48…1.0, so ×12 reaches the clamp at the low end); MAX_PRIOR_SHIFT is the
@@ -1595,6 +1649,20 @@ function numEnv(name, dflt) {
1595
1649
  // a stream); rejecting it silently re-enabled the default weight (issue #33).
1596
1650
  return Number.isFinite(v) && v >= 0 ? v : dflt;
1597
1651
  }
1652
+ function boundedWhole(value, dflt, hardMax) {
1653
+ if (value === undefined || !Number.isFinite(value) || value < 0)
1654
+ return dflt;
1655
+ return Math.min(Math.floor(value), hardMax);
1656
+ }
1657
+ function isGraphContextRef(ref) {
1658
+ return ref.startsWith("sym_") || ref.startsWith("cmp_");
1659
+ }
1660
+ function compareRefs(a, b) {
1661
+ return a < b ? -1 : a > b ? 1 : 0;
1662
+ }
1663
+ function estimatedSearchHitTokens(hit) {
1664
+ return Math.max(1, Math.ceil([...`${hit.kind} ${hit.ref}\n${hit.title}\n${hit.snippet}`].length / 4));
1665
+ }
1598
1666
  /** Pack a vector's exact bytes for SQLite. Explicit offset+length so a SUBARRAY
1599
1667
  * view (byteOffset != 0) writes only its slice, not the whole backing buffer.
1600
1668
  * node:sqlite copies on bind, so the returned view never aliases the row. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.13.1",
3
+ "version": "1.15.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
@@ -74,6 +74,7 @@
74
74
  "@modelcontextprotocol/sdk": "^1.29.0",
75
75
  "commander": "^15.0.0",
76
76
  "tree-sitter": "0.21.1",
77
+ "tree-sitter-go": "^0.23.4",
77
78
  "tree-sitter-python": "^0.23.2",
78
79
  "tree-sitter-typescript": "^0.23.2",
79
80
  "zod": "^4.4.3"
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://hunch-pi.vercel.app",
10
- "version": "1.13.1",
10
+ "version": "1.15.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.13.1",
16
+ "version": "1.15.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {