@davesheffer/hunch 1.13.0 → 1.14.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
@@ -16,8 +16,8 @@ strict enforcement.
16
16
  **Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
17
17
  then a deterministic check of the change against the rules your team has explicitly trusted.
18
18
 
19
- > **New in v1.13.0:** CLI, MCP, and edit hooks now share one provenance-checked, hard-budgeted
20
- > delivery envelope, and local receipts record exactly what reached the agent and why.
19
+ > **New in v1.13.1:** `hunch_context` exposes that provenance-checked, hard-budgeted delivery
20
+ > envelope as MCP structured output and records exactly which returned items reached the client.
21
21
 
22
22
  See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
23
23
 
@@ -82,7 +82,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
82
82
  have one maintainer run:
83
83
 
84
84
  ```bash
85
- npm i -g @davesheffer/hunch@1.13.0
85
+ npm i -g @davesheffer/hunch@1.13.1
86
86
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
87
87
  git add .gitignore .hunch/team.json
88
88
  git commit -m "chore: connect shared Hunch memory"
@@ -97,7 +97,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
97
97
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
98
98
 
99
99
  ```bash
100
- npm i -g @davesheffer/hunch@1.13.0
100
+ npm i -g @davesheffer/hunch@1.13.1
101
101
  git pull
102
102
  hunch init
103
103
  hunch doctor
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);
@@ -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,10 +18,12 @@ 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
- import { formatContext, formatStructure } from "../core/format.js";
24
+ import { formatStructure } from "../core/format.js";
25
+ import { buildDeliveryEnvelope } from "../core/delivery.js";
26
+ import { recordServed } from "../core/served.js";
25
27
  import { compareCandidates } from "../core/compare.js";
26
28
  import { checkConformance } from "../core/conformance.js";
27
29
  import { ConstitutionService, policyEvaluationEnvelope } from "../constitution/service.js";
@@ -41,6 +43,23 @@ import { existsSync } from "node:fs";
41
43
  import { join } from "node:path";
42
44
  const ok = (text) => ({ content: [{ type: "text", text }] });
43
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
+ }
44
63
  /** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
45
64
  * commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
46
65
  * next flush sweeps it up; claiming "auto-committed" there would be a lie. */
@@ -71,6 +90,21 @@ const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
71
90
  return risk;
72
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;
73
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;
74
108
  // Read-side token budgets: every tool result is injected into a Claude Code
75
109
  // session, so an uncapped list pollutes the context window. Cap each list to its
76
110
  // highest-signal head (records are pre-sorted by severity/confidence) and tell the
@@ -82,6 +116,61 @@ const FINDINGS_CAP = 12; // hunch_findings listing
82
116
  const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
83
117
  const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
84
118
  const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
119
+ /** Public MCP shape for the canonical delivery envelope. Keeping the schema on
120
+ * the tool means orchestrators can consume receipt facts without scraping the
121
+ * backward-compatible text block. */
122
+ const DELIVERY_OUTPUT_SCHEMA = z.object({
123
+ text: z.string(),
124
+ delivered: z.array(z.object({
125
+ kind: z.enum(["constraints", "decisions", "bugs", "findings"]),
126
+ record_id: z.string(),
127
+ rank: z.number().int().positive(),
128
+ delivery_reason: z.enum(["ranked", "blocking-reserved"]),
129
+ provenance_status: z.enum(["current", "unverified", "stale"]),
130
+ token_cost: z.number().int().nonnegative(),
131
+ })),
132
+ supplements: z.array(z.object({
133
+ id: z.string(),
134
+ kind: z.string(),
135
+ delivered: z.boolean(),
136
+ reason: z.enum(["supplemental", "budget", "empty"]),
137
+ rank: z.number().int().positive(),
138
+ token_cost: z.number().int().nonnegative(),
139
+ })),
140
+ omitted: z.array(z.object({
141
+ kind: z.enum(["constraints", "decisions", "bugs", "findings"]),
142
+ record_id: z.string(),
143
+ reason: z.enum(["budget", "stale-provenance", "retired"]),
144
+ detail: z.string(),
145
+ })),
146
+ budget_tokens: z.number().int().nonnegative(),
147
+ used_chars: z.number().int().nonnegative(),
148
+ blocking_overflow: z.boolean(),
149
+ });
150
+ /** Return the same human-readable brief older clients consume plus the exact
151
+ * machine-readable envelope. Receipt recording is deliberately best-effort:
152
+ * recordServed never throws, so telemetry can never cost a delivery. */
153
+ function deliveredContext(root, target, envelope, sessionId) {
154
+ // Validate before recording: if a future envelope change drifts from the
155
+ // advertised MCP contract, the SDK will reject the call and the local ledger
156
+ // must not claim that response was served.
157
+ const structuredContent = DELIVERY_OUTPUT_SCHEMA.parse(envelope);
158
+ recordServed(root, structuredContent.delivered.map((item) => ({
159
+ event: "served",
160
+ kind: item.kind,
161
+ record_id: item.record_id,
162
+ target,
163
+ session_id: sessionId,
164
+ rank: item.rank,
165
+ delivery_reason: item.delivery_reason,
166
+ provenance_status: item.provenance_status,
167
+ token_cost: item.token_cost,
168
+ })));
169
+ return {
170
+ content: [{ type: "text", text: structuredContent.text }],
171
+ structuredContent,
172
+ };
173
+ }
85
174
  // Capture-session tokens live in src/core/capturetoken.ts (pure + testable). These
86
175
  // thin wrappers bind the process clock and id source at the call site (§5 Stage 1).
87
176
  const issueCaptureToken = () => issueToken(randomUUID, Date.now());
@@ -378,6 +467,30 @@ export function buildServerWithRootControl(initialRoot) {
378
467
  };
379
468
  const registerTool = server.registerTool.bind(server);
380
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
+ }
381
494
  activeRequests++;
382
495
  try {
383
496
  // Routing is live state, not a startup constant. A branch switch or
@@ -589,11 +702,19 @@ export function buildServerWithRootControl(initialRoot) {
589
702
  budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
590
703
  as_of: z.string().optional().describe("Time-travel ref (commit/tag/branch): assemble the slice as it stood then."),
591
704
  },
592
- }, async ({ target, budget_tokens, as_of }) => {
705
+ outputSchema: DELIVERY_OUTPUT_SCHEMA,
706
+ }, async ({ target, budget_tokens, as_of }, extra) => {
593
707
  const asOf = as_of ? asOfDate(as_of, root) : undefined;
594
708
  if (as_of && !asOf)
595
709
  return err(`Could not resolve as_of "${as_of}" to a commit.`);
596
710
  const ctx = store.assembleContext(target, budget_tokens ?? 1500, { asOf });
711
+ const options = {
712
+ root,
713
+ symbols: store.recs("symbols"),
714
+ components: store.recs("components"),
715
+ decisionCorpus: store.recs("decisions"),
716
+ historical: !!asOf,
717
+ };
597
718
  // Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
598
719
  // used to return an empty brief while the graph held the answer — fall back to
599
720
  // FTS so the assistant always leaves with the closest matches, not a shrug.
@@ -601,17 +722,30 @@ export function buildServerWithRootControl(initialRoot) {
601
722
  if (empty && !asOf) {
602
723
  const hits = store.search(target, 8);
603
724
  if (hits.length) {
604
- const lines = hits.map((h) => `• ${h.ref} ${h.title}\n ${h.snippet}`);
605
- return ok(`No file/symbol resolves for "${target}" — closest graph matches instead:\n\n${lines.join("\n")}\n\n(For a file/symbol brief pass a concrete target; free-text goes through the same search as hunch_query.)`);
725
+ const resolved = hits.map((hit) => ({ hit, record: store.resolve(hit.ref)?.record }));
726
+ const fallback = {
727
+ ...ctx,
728
+ constraints: resolved.filter(({ hit, record }) => hit.kind === "constraints" && !!record).map(({ record }) => record),
729
+ decisions: resolved.filter(({ hit, record }) => hit.kind === "decisions" && !!record).map(({ record }) => record),
730
+ bugs: resolved.filter(({ hit, record }) => hit.kind === "bugs" && !!record).map(({ record }) => record),
731
+ findings: resolved.filter(({ hit, record }) => hit.kind === "findings" && !!record).map(({ record }) => record),
732
+ };
733
+ const envelope = buildDeliveryEnvelope(fallback, {
734
+ ...options,
735
+ supplements: hits
736
+ .filter((hit) => !["constraints", "decisions", "bugs", "findings"].includes(hit.kind))
737
+ .map((hit, index) => ({
738
+ id: hit.ref,
739
+ kind: `search-${hit.kind}`,
740
+ text: `${hit.ref} — ${hit.title}: ${hit.snippet}`,
741
+ priority: 100 - index,
742
+ })),
743
+ });
744
+ return deliveredContext(root, target, envelope, extra.sessionId);
606
745
  }
607
746
  }
608
- return ok(formatContext(ctx, {
609
- root,
610
- symbols: store.recs("symbols"),
611
- components: store.recs("components"),
612
- decisionCorpus: store.recs("decisions"),
613
- historical: !!asOf,
614
- }));
747
+ const envelope = buildDeliveryEnvelope(ctx, options);
748
+ return deliveredContext(root, as_of ? `${target} (as_of:${as_of})` : target, envelope, extra.sessionId);
615
749
  });
616
750
  // -- hunch_now (the hot view: recent activity + roadmap) --------------------
617
751
  // PUBLIC store only, per dec_29eff08c69's jurisdiction rule: an assistant may
@@ -779,6 +913,7 @@ export function buildServerWithRootControl(initialRoot) {
779
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."),
780
914
  }),
781
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,
782
917
  },
783
918
  }, async ({ decision, capture_token }) => {
784
919
  try {
@@ -951,7 +1086,8 @@ export function buildServerWithRootControl(initialRoot) {
951
1086
  const where = decision.private
952
1087
  ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
953
1088
  : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
954
- 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}`);
955
1091
  }
956
1092
  catch (e) {
957
1093
  return err(`Failed to record decision: ${e.message}`);
@@ -971,6 +1107,7 @@ export function buildServerWithRootControl(initialRoot) {
971
1107
  source_decision: z.string().optional().describe("id of a decision this correction derives from."),
972
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."),
973
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,
974
1111
  },
975
1112
  }, async (input) => {
976
1113
  try {
@@ -1025,7 +1162,8 @@ export function buildServerWithRootControl(initialRoot) {
1025
1162
  : `
1026
1163
 
1027
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).`;
1028
- 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}`);
1029
1167
  }
1030
1168
  catch (e) {
1031
1169
  return err(`Failed to record correction: ${e.message}`);
@@ -1050,6 +1188,7 @@ export function buildServerWithRootControl(initialRoot) {
1050
1188
  resolved_commit: z.string().optional().describe("the commit that fixed it (with triage:'resolved')"),
1051
1189
  private: z.boolean().optional().describe("write into the PRIVATE overlay store instead of the committed repo. Errors if no private store is configured."),
1052
1190
  }),
1191
+ cwd: cwdHintField,
1053
1192
  },
1054
1193
  }, async ({ finding }) => {
1055
1194
  try {
@@ -1094,7 +1233,8 @@ export function buildServerWithRootControl(initialRoot) {
1094
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.`
1095
1234
  : "";
1096
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.";
1097
- 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}`);
1098
1238
  }
1099
1239
  catch (e) {
1100
1240
  return err(`Failed to record finding: ${e.message}`);
@@ -1129,6 +1269,7 @@ export function buildServerWithRootControl(initialRoot) {
1129
1269
  public_only: z.boolean().optional().describe("Read and write only the public correction home."),
1130
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."),
1131
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,
1132
1273
  },
1133
1274
  }, async ({ constraint_id, public_only, private_only, include_artifacts }) => {
1134
1275
  try {
@@ -1166,8 +1307,10 @@ export function buildServerWithRootControl(initialRoot) {
1166
1307
  if (artifactHome === "private") {
1167
1308
  flushMemoryHome(store, hunchPaths(root).hunch, "private", `hunch: prove correction ${constraint_id}`, startupTeamRoute ?? undefined);
1168
1309
  }
1310
+ const destRoot = resolveDestRoot(artifactHome, store, root);
1311
+ const destination = { root: destRoot, branch: currentBranch(destRoot) };
1169
1312
  if (include_artifacts)
1170
- return ok(JSON.stringify(upgrade, null, 2));
1313
+ return ok(JSON.stringify({ ...upgrade, destination }, null, 2));
1171
1314
  return ok(JSON.stringify({
1172
1315
  status: upgrade.status,
1173
1316
  correction_id: upgrade.correction_id,
@@ -1180,6 +1323,7 @@ export function buildServerWithRootControl(initialRoot) {
1180
1323
  authority: upgrade.authority,
1181
1324
  effects: upgrade.effects,
1182
1325
  activation: upgrade.activation,
1326
+ destination,
1183
1327
  }, null, 2));
1184
1328
  }
1185
1329
  catch (e) {
@@ -1338,6 +1482,7 @@ export function buildServerWithRootControl(initialRoot) {
1338
1482
  inputSchema: {
1339
1483
  policy_id: z.string().describe("Policy id (pol_*)."),
1340
1484
  public_only: z.boolean().optional().describe("Exclude private-overlay policy and evidence records."),
1485
+ cwd: cwdHintField,
1341
1486
  },
1342
1487
  }, async ({ policy_id, public_only }) => {
1343
1488
  try {
@@ -1347,7 +1492,8 @@ export function buildServerWithRootControl(initialRoot) {
1347
1492
  if (!home)
1348
1493
  throw new Error(`policy ${policy_id} has no exact storage home`);
1349
1494
  flushMemoryHome(store, hunchPaths(root).hunch, home, `hunch: plan policy ${policy_id}`, startupTeamRoute ?? undefined);
1350
- 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));
1351
1497
  }
1352
1498
  catch (e) {
1353
1499
  return err(`Failed to generate policy proof plan: ${e.message}`);
@@ -1614,6 +1760,7 @@ export function buildServerWithRootControl(initialRoot) {
1614
1760
  limit: z.number().int().min(1).max(100).optional().describe("Item limit used by the exact review packet (default 30)."),
1615
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."),
1616
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,
1617
1764
  },
1618
1765
  }, async ({ decision_id, since, max_commits, limit, allow_install_scripts, dependency_timeout_ms }) => {
1619
1766
  try {
@@ -1626,7 +1773,8 @@ export function buildServerWithRootControl(initialRoot) {
1626
1773
  dependencyTimeoutMs: dependency_timeout_ms ?? 300_000,
1627
1774
  });
1628
1775
  flushMemoryHome(store, hunchPaths(root).hunch, "private", "hunch: materialize G2 behavior policies", startupTeamRoute ?? undefined);
1629
- 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));
1630
1778
  }
1631
1779
  catch (e) {
1632
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.0",
3
+ "version": "1.14.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
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.0",
10
+ "version": "1.14.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.0",
16
+ "version": "1.14.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {