@davesheffer/hunch 1.16.0 → 1.17.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
@@ -6,9 +6,10 @@
6
6
  [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
7
7
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
8
8
 
9
- Hunch is the engineering-memory and architectural-conformance layer for AI-assisted codebases. It
10
- records the decisions, constraints, rejected approaches, and bug history behind your code, delivers
11
- the relevant evidence before an assistant changes anything, then checks the result deterministically.
9
+ Hunch is a guarantee: **your agents never re-make a decided decision, and never re-introduce a
10
+ fixed bug.** The mechanism behind it is an engineering-memory and architectural-conformance layer
11
+ the decisions, constraints, rejected approaches, and bug history behind your code, delivered as
12
+ evidence before an assistant changes anything, with the result checked deterministically after.
12
13
 
13
14
  Memory starts **advisory**. Nothing blocks until you explicitly trust a precise rule and choose
14
15
  strict enforcement.
@@ -16,8 +17,10 @@ strict enforcement.
16
17
  **Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
17
18
  then a deterministic check of the change against the rules your team has explicitly trusted.
18
19
 
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.
20
+ > **New in v1.17.0:** an exported ADR corpus now tracks the graph automatically and reports its
21
+ > own drift (`madr-stale` / `madr-edited` / `madr-orphan`), and retrieval ranks recorded intent
22
+ > above code symbols that merely share the query's vocabulary — Recall@10 70% → 90% on the
23
+ > curated benchmark.
21
24
 
22
25
  See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
23
26
 
@@ -47,8 +50,11 @@ to the same graph. It merges into existing configuration instead of replacing it
47
50
  - **Change receipts** — review a working tree, commit, or branch against recorded intent and get a
48
51
  cited PASS / WARN / BLOCK result.
49
52
  - **Bug lineage** — understand which old incident a line fixed before accidentally undoing it.
50
- - **Code awareness** — TypeScript, JavaScript, and Python structure feed dependency, blast-radius,
51
- and redundancy checks. The reasoning layer works with any language.
53
+ - **Code awareness** — TypeScript, JavaScript, Python, and Go structure feed dependency,
54
+ blast-radius, and redundancy checks. The reasoning layer works with any language.
55
+ - **ADR interop** — `hunch import-adr` populates the graph from an existing MADR/Nygard corpus;
56
+ `hunch export-adr` projects it back as standard MADR any ADR reader understands, and the
57
+ projection then tracks the graph automatically and reports its own drift.
52
58
 
53
59
  The source of truth is readable JSON in `.hunch/`. A local SQLite index makes retrieval fast but
54
60
  is always rebuildable.
package/dist/cli/index.js CHANGED
@@ -73,6 +73,7 @@ import { adoptProsePrompt } from "../wiki/adopt.js";
73
73
  import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
74
74
  import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
75
75
  import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
76
+ import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
76
77
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
77
78
  import { premiseEscalations } from "../core/premises.js";
78
79
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
@@ -500,6 +501,21 @@ program
500
501
  else if (!opts.quiet) {
501
502
  console.log(`· skipped: ${r.reason}`);
502
503
  }
504
+ // The MADR projection tracks the graph automatically once adopted, the way the
505
+ // SQLite index does — a user who ran `hunch export-adr` once never runs it again.
506
+ // Best-effort and last-write-wins-free: a hand-edited file is skipped, not
507
+ // clobbered, and any failure here must never affect the capture that preceded it.
508
+ try {
509
+ const refreshed = refreshMadrCorpus(store.json.loadAll("decisions"), root, new Date().toISOString());
510
+ if (refreshed && !opts.quiet && (refreshed.written || refreshed.removed || refreshed.skippedEdited.length)) {
511
+ const skipped = refreshed.skippedEdited.length ? `, ${refreshed.skippedEdited.length} hand-edited file(s) left alone` : "";
512
+ console.log(` ↳ ADR corpus refreshed: ${refreshed.written} written, ${refreshed.removed} removed${skipped} (${refreshed.dir}/)`);
513
+ }
514
+ }
515
+ catch (e) {
516
+ if (!opts.quiet)
517
+ console.log(` ↳ ADR corpus refresh skipped safely: ${e.message}`);
518
+ }
503
519
  let graphRefreshed = false;
504
520
  let publicCorrectionQueued = false;
505
521
  let privateCorrectionQueued = false;
@@ -3097,6 +3113,10 @@ program
3097
3113
  // are OURS (marker-verified) and stale — remove so the corpus stays coherent.
3098
3114
  for (const f of stale)
3099
3115
  rmSync(join(outDir, f));
3116
+ // Adopt the corpus: the manifest is what makes `hunch drift` able to notice
3117
+ // this projection going stale, being hand-edited, or outliving its decision.
3118
+ // Written after the files land, so a failed write never claims freshness.
3119
+ writeMadrManifest(root, buildMadrManifest(dir, files, new Date().toISOString()));
3100
3120
  console.log(`✓ exported ${files.length} ADR(s) to ${dir}${stale.length ? `; removed ${stale.length} stale generated file(s)` : ""}`);
3101
3121
  console.log(` ↳ Backstage: add to catalog-info.yaml metadata.annotations → ${backstageAnnotation}`);
3102
3122
  }
@@ -5223,6 +5243,30 @@ program
5223
5243
  console.log(`· ${f.id} — ${f.detail}`);
5224
5244
  console.log(`\nHeal: run \`hunch wiki --heal\` — regenerates only the stale pages (the wiki is a derived view; never edit it by hand).\n`);
5225
5245
  }
5246
+ // The MADR projection: three kinds, three different human actions — which is
5247
+ // why they are separate sections rather than one "run export-adr" line.
5248
+ const madrStale = kind("madr-stale");
5249
+ if (madrStale.length) {
5250
+ console.log(`${madrStale.length} exported ADR(s) drifted from the graph:\n`);
5251
+ for (const f of madrStale)
5252
+ console.log(`· ${f.id} — ${f.detail}`);
5253
+ console.log(`\nHeal: run \`hunch export-adr\` — the corpus is a disposable projection (the graph stays the source of truth). Normally this never appears: the projection refreshes automatically on every capture.\n`);
5254
+ }
5255
+ const madrEdited = kind("madr-edited");
5256
+ if (madrEdited.length) {
5257
+ console.log(`${madrEdited.length} generated ADR(s) were hand-edited — the next export would overwrite them:\n`);
5258
+ for (const f of madrEdited)
5259
+ console.log(`· ${f.id} — ${f.detail}`);
5260
+ console.log(`\nHeal A (the DECISION is what changed): move the edit into the decision via /capture, then let the projection regenerate — the edit survives because it now lives in the graph.`);
5261
+ console.log(`Heal B (you want to own this file): delete the hunch:generated marker. The export refuses it from then on and it becomes a hand-written ADR.\n`);
5262
+ }
5263
+ const madrOrphan = kind("madr-orphan");
5264
+ if (madrOrphan.length) {
5265
+ console.log(`${madrOrphan.length} generated ADR(s) have no decision behind them any more:\n`);
5266
+ for (const f of madrOrphan)
5267
+ console.log(`· ${f.id} — ${f.detail}`);
5268
+ console.log(`\nHeal: delete the file, or run \`hunch export-adr\` to regenerate the corpus without it. If the decision moved to the private overlay, the file is a PUBLIC artifact of a now-private record — delete it.\n`);
5269
+ }
5226
5270
  // Every drift kind heals here — see bug_drift_heal_asymmetry above. premise-stale
5227
5271
  // shipped in the drift report without a section here, so a repo whose ONLY drift
5228
5272
  // was a dead premise got "N findings" from `hunch drift` and a bare closing line
@@ -11,6 +11,11 @@
11
11
  * (hash-compared via .hunch/wiki-manifest.json; only when a wiki
12
12
  * was adopted — see src/wiki/wiki.ts). Advisory, healed by
13
13
  * `hunch wiki --heal`, never a gate.
14
+ * - madr-*: the exported MADR corpus drifted from the graph — stale (the
15
+ * decision moved), edited (a human changed a generated file the
16
+ * next export would overwrite), or orphan (the decision left the
17
+ * public graph). Only when a corpus was exported; healed by
18
+ * `hunch export-adr`. See src/integrations/madrManifest.ts.
14
19
  */
15
20
  import { existsSync, readFileSync } from "node:fs";
16
21
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -20,6 +25,7 @@ import { evaluatePremises } from "./premises.js";
20
25
  import { parseDocAnchors } from "./docanchors.js";
21
26
  import { markdownDocs, STALE_MARKER, SRC_REF } from "./docscan.js";
22
27
  import { computeWikiDrift } from "../wiki/wiki.js";
28
+ import { computeMadrDrift } from "../integrations/madrManifest.js";
23
29
  export function computeDrift(store, root) {
24
30
  const findings = [];
25
31
  const decisions = store.recs("decisions");
@@ -142,6 +148,12 @@ export function computeDrift(store, root) {
142
148
  // component vanished). Deterministic hash comparison against the manifest;
143
149
  // fires only when a wiki was adopted. Advisory like every other kind here.
144
150
  findings.push(...computeWikiDrift(store, root));
151
+ // 6b. MADR-* — the exported ADR corpus drifted from the graph. PUBLIC decisions
152
+ // only: the corpus is a committable artifact, so its freshness must be
153
+ // computed from exactly the records allowed to reach it (an overlay record
154
+ // leaking into a public drift report is the same class of bug as one
155
+ // leaking into the export itself). Fires only where a corpus was exported.
156
+ findings.push(...computeMadrDrift(store.json.loadAll("decisions"), root));
145
157
  // 7. FINDING-STALE — a LIVE finding (observation, no diff) whose anchor evaporated:
146
158
  // an affected file that no longer exists, or a violates_constraint pointing at a
147
159
  // retired/missing rule. Deterministic + advisory (never the exit-code class):
@@ -0,0 +1,251 @@
1
+ /**
2
+ * MADR projection freshness — the manifest and the drift it feeds.
3
+ *
4
+ * The export half (madrExport.ts) renders the graph into a disposable MADR
5
+ * corpus. Nothing, until now, noticed when that corpus went stale: the wiki gets
6
+ * `wiki-stale` when its inputs move, but an exported ADR file could sit in
7
+ * `docs/adr/` confidently wrong forever. This closes that seam with the exact
8
+ * mechanism the wiki already proves out — a content-hash manifest, adopted on
9
+ * first export, silent when absent.
10
+ *
11
+ * Two hashes per file, and they answer different questions:
12
+ * - `hash` — the decision's projected content. Moves when the GRAPH moves,
13
+ * so a mismatch means "the projection is behind the graph".
14
+ * - `bytes` — the file as written. Moves when a HUMAN edits it, so a mismatch
15
+ * means "someone hand-edited a generated file", which the export
16
+ * marker warns against but nothing detected.
17
+ *
18
+ * Three findings, matching the three ways a projection can rot:
19
+ * - madr-stale the decision changed since export (or the file is gone)
20
+ * - madr-edited a generated file was hand-edited; the next export overwrites it
21
+ * - madr-orphan a generated file whose decision no longer exists in the graph
22
+ *
23
+ * All advisory, like every other drift kind: this is a smoke detector, not a
24
+ * robot that rewrites the corpus. `hunch export-adr` is the heal.
25
+ */
26
+ import { createHash } from "node:crypto";
27
+ import { existsSync, readFileSync, rmSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { writeFileAtomic } from "../core/io.js";
30
+ import { hunchPaths } from "../core/paths.js";
31
+ import { toPosixTarget } from "../core/paths.js";
32
+ import { exportMadrCorpus, isRegenerableMadr } from "./madrExport.js";
33
+ const sha16 = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
34
+ export function madrManifestPath(root) {
35
+ return join(hunchPaths(root).hunch, "madr-manifest.json");
36
+ }
37
+ export function readMadrManifest(root) {
38
+ try {
39
+ const raw = JSON.parse(readFileSync(madrManifestPath(root), "utf8"));
40
+ if (!raw || raw.version !== 1 || typeof raw.dir !== "string" || !raw.files || typeof raw.files !== "object")
41
+ return null;
42
+ // Drop malformed entries rather than crashing every drift-bearing command,
43
+ // the same tolerance readWikiManifestAt applies to a bad merge.
44
+ raw.files = Object.fromEntries(Object.entries(raw.files).filter(([, f]) => f && typeof f === "object" && typeof f.decision === "string" && typeof f.hash === "string"));
45
+ return raw;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export function writeMadrManifest(root, manifest) {
52
+ writeFileAtomic(madrManifestPath(root), JSON.stringify(manifest, null, 2) + "\n");
53
+ }
54
+ /** The projected-content hash for one file. Content, not mtime: a re-export that
55
+ * changes nothing must not read as drift. */
56
+ export const madrContentHash = (text) => sha16(text);
57
+ /**
58
+ * Build the manifest for a corpus that was just written.
59
+ *
60
+ * `written` carries the bytes actually placed on disk, which may differ from the
61
+ * rendered text when a file was refused (hand-written corpus in the target dir).
62
+ * Refused files are absent from the manifest, so they are never later reported
63
+ * as edited — they were never ours.
64
+ */
65
+ export function buildMadrManifest(dir, entries, generatedAt) {
66
+ const files = {};
67
+ for (const e of entries) {
68
+ files[e.name] = {
69
+ decision: e.decisionId,
70
+ hash: madrContentHash(e.text),
71
+ bytes: sha16(e.text),
72
+ generated: generatedAt,
73
+ };
74
+ }
75
+ return { version: 1, dir: toPosixTarget(dir), files };
76
+ }
77
+ /**
78
+ * Drift for the MADR projection. Fires ONLY where a manifest exists — a repo that
79
+ * never ran `hunch export-adr` sees zero noise, matching the wiki's rule.
80
+ *
81
+ * Takes the PUBLIC decision list, never the overlay union: the projection is a
82
+ * committable artifact, so its freshness must be computed from exactly the
83
+ * records that are allowed to reach it. Passing the union here would leak the
84
+ * existence of overlay decisions into a public drift report.
85
+ */
86
+ export function computeMadrDrift(publicDecisions, root) {
87
+ const manifest = readMadrManifest(root);
88
+ if (!manifest)
89
+ return []; // never exported → silent
90
+ const findings = [];
91
+ // Re-render from the current graph. Numbering is assigned per export, so a file
92
+ // name is only stable while the decision set is; compare by DECISION id, which
93
+ // is the thing that actually has identity.
94
+ const { files } = exportMadrCorpus(publicDecisions, manifest.dir);
95
+ const currentByDecision = new Map(files.map((f) => [f.decisionId, f]));
96
+ const liveIds = new Set(publicDecisions.map((d) => d.id));
97
+ for (const [name, entry] of Object.entries(manifest.files)) {
98
+ const rel = `${manifest.dir}/${name}`;
99
+ const abs = join(root, manifest.dir, name);
100
+ // 1. ORPHAN — the decision left the public graph (deleted, or moved to the
101
+ // overlay). The file is now a public artifact with no record behind it,
102
+ // which is the shape of a leak as much as of staleness.
103
+ if (!liveIds.has(entry.decision)) {
104
+ findings.push({
105
+ kind: "madr-orphan",
106
+ id: rel,
107
+ detail: `generated ADR has no decision in the public graph (${entry.decision} is gone or moved to the overlay) — delete it, or re-run \`hunch export-adr\``,
108
+ });
109
+ continue;
110
+ }
111
+ // 2. MISSING — manifested but not on disk.
112
+ if (!existsSync(abs)) {
113
+ findings.push({
114
+ kind: "madr-stale",
115
+ id: rel,
116
+ detail: `generated ADR for ${entry.decision} is missing from ${manifest.dir}/ — regenerate with \`hunch export-adr\``,
117
+ });
118
+ continue;
119
+ }
120
+ let onDisk;
121
+ try {
122
+ onDisk = readFileSync(abs, "utf8");
123
+ }
124
+ catch {
125
+ continue; // unreadable is an environment problem, not memory drift
126
+ }
127
+ // 3. HAND-EDITED — the bytes moved and the marker is still there, so the next
128
+ // export silently overwrites human work. Report before that happens.
129
+ // A file whose marker was REMOVED is deliberately not ours any more: the
130
+ // export already refuses it, and calling that drift would nag forever.
131
+ if (sha16(onDisk) !== entry.bytes && isRegenerableMadr(onDisk)) {
132
+ findings.push({
133
+ kind: "madr-edited",
134
+ id: rel,
135
+ detail: `generated ADR was hand-edited — \`hunch export-adr\` will overwrite it. Move the change into decision ${entry.decision} (\`/capture\`), or drop the hunch:generated marker to adopt the file`,
136
+ });
137
+ }
138
+ // 4. STALE — the graph moved underneath the projection.
139
+ const current = currentByDecision.get(entry.decision);
140
+ if (current && madrContentHash(current.text) !== entry.hash) {
141
+ findings.push({
142
+ kind: "madr-stale",
143
+ id: rel,
144
+ detail: `decision ${entry.decision} changed since export — regenerate with \`hunch export-adr\``,
145
+ });
146
+ }
147
+ }
148
+ // 5. UNEXPORTED — a public decision with no file at all. Only reported once a
149
+ // corpus exists, so adopting the export does not immediately indict every
150
+ // decision recorded before it.
151
+ const manifested = new Set(Object.values(manifest.files).map((f) => f.decision));
152
+ const missing = publicDecisions.filter((d) => !manifested.has(d.id));
153
+ if (missing.length) {
154
+ findings.push({
155
+ kind: "madr-stale",
156
+ id: manifest.dir,
157
+ detail: `${missing.length} public decision(s) have no ADR in ${manifest.dir}/ (e.g. ${missing[0].id}) — regenerate with \`hunch export-adr\``,
158
+ });
159
+ }
160
+ return findings;
161
+ }
162
+ export function refreshMadrCorpus(publicDecisions, root, now) {
163
+ const manifest = readMadrManifest(root);
164
+ if (!manifest)
165
+ return null; // never adopted → stay out of the way
166
+ const { files } = exportMadrCorpus(publicDecisions, manifest.dir);
167
+ const outDir = join(root, manifest.dir);
168
+ if (!existsSync(outDir))
169
+ return null; // corpus deleted wholesale; drift reports it
170
+ const skippedEdited = [];
171
+ const kept = [];
172
+ /** Manifest entries carried through verbatim (hand-edited files we refused to touch). */
173
+ const preserved = new Map();
174
+ let written = 0;
175
+ // Edit detection is keyed by CONTENT, not by file name. Numbering is assigned
176
+ // per export, so adding one decision shifts every later file to a new name —
177
+ // a name-keyed check then finds no prior entry for the shifted name, calls the
178
+ // hand-edited file at that path "stale", and overwrites it (and the removal
179
+ // sweep would delete it under its old name). Bytes we have ever written are
180
+ // exactly the manifest's `bytes` values: an on-disk generated file whose hash
181
+ // is not among them was edited by a human, whatever it is currently called.
182
+ const knownBytes = new Set(Object.values(manifest.files).map((entry) => entry.bytes));
183
+ for (const f of files) {
184
+ const abs = join(outDir, f.name);
185
+ const prior = manifest.files[f.name];
186
+ if (existsSync(abs)) {
187
+ let onDisk;
188
+ try {
189
+ onDisk = readFileSync(abs, "utf8");
190
+ }
191
+ catch {
192
+ continue;
193
+ }
194
+ // Someone else's file: leave it alone (its decision then surfaces as
195
+ // "no ADR" drift rather than being silently unrepresented).
196
+ if (!isRegenerableMadr(onDisk))
197
+ continue;
198
+ if (onDisk === f.text) {
199
+ kept.push(f);
200
+ continue; // already current — no write, no churn
201
+ }
202
+ const bytes = sha16(onDisk);
203
+ const edited = prior ? bytes !== prior.bytes : !knownBytes.has(bytes);
204
+ if (edited) {
205
+ skippedEdited.push(f.name);
206
+ // Carry the PRIOR entry through untouched (when one exists). Rebuilding
207
+ // it from the edited bytes would make the file match its own manifest
208
+ // and the edit would stop being reported — the refresh would quietly
209
+ // launder a hand edit into the record of what we generated.
210
+ if (prior)
211
+ preserved.set(f.name, prior);
212
+ continue;
213
+ }
214
+ }
215
+ writeFileAtomic(abs, f.text);
216
+ kept.push(f);
217
+ written++;
218
+ }
219
+ // Drop generated files the new numbering no longer produces — but ONLY files
220
+ // whose bytes we wrote. An edited file under its old name is preserved (with
221
+ // its manifest entry, so madr-edited keeps firing) rather than deleted: this
222
+ // sweep was the second way a renumbering could destroy a human's edit.
223
+ let removed = 0;
224
+ const produced = new Set(files.map((f) => f.name));
225
+ for (const name of Object.keys(manifest.files)) {
226
+ if (produced.has(name))
227
+ continue;
228
+ const abs = join(outDir, name);
229
+ if (!existsSync(abs))
230
+ continue;
231
+ try {
232
+ const onDisk = readFileSync(abs, "utf8");
233
+ if (!isRegenerableMadr(onDisk))
234
+ continue;
235
+ if (!knownBytes.has(sha16(onDisk))) {
236
+ skippedEdited.push(name);
237
+ preserved.set(name, manifest.files[name]);
238
+ continue;
239
+ }
240
+ rmSync(abs);
241
+ removed++;
242
+ }
243
+ catch { /* best effort */ }
244
+ }
245
+ const next = buildMadrManifest(manifest.dir, kept, now);
246
+ for (const [name, entry] of preserved)
247
+ next.files[name] = entry;
248
+ writeMadrManifest(root, next);
249
+ return { dir: manifest.dir, written, removed, skippedEdited };
250
+ }
251
+ //# sourceMappingURL=madrManifest.js.map
@@ -592,7 +592,11 @@ export class HunchStore {
592
592
  }
593
593
  // w > 1 (a trigger match) shifts UP, w < 1 shifts DOWN, both clamped.
594
594
  const shift = Math.max(-MAX_PRIOR_SHIFT, Math.min(MAX_PRIOR_SHIFT, (1 - w) * PRIOR_SHIFT_SCALE));
595
- return { h, pos: pos + shift };
595
+ // Recorded intent outranks code that merely shares the query's vocabulary. Applied
596
+ // OUTSIDE the clamp above: that bound keeps the trust dimmer from becoming the sort
597
+ // key, whereas this is a kind-level tie-break between two different answer types.
598
+ const memory = MEMORY_KINDS.has(h.kind) ? -MEMORY_PRIOR_SHIFT : 0;
599
+ return { h, pos: pos + shift + memory };
596
600
  });
597
601
  scored.sort((a, b) => a.pos - b.pos);
598
602
  return scored.slice(0, limit).map((x) => x.h);
@@ -1643,6 +1647,18 @@ const GRAPH_TOKEN_CAP = boundedWhole(numEnv("HUNCH_GRAPH_TOKEN_CAP", 2_000), 2_0
1643
1647
  * rerankByPriors for the measurement that fixed it at 4. */
1644
1648
  const PRIOR_SHIFT_SCALE = numEnv("HUNCH_PRIOR_SHIFT_SCALE", 12);
1645
1649
  const MAX_PRIOR_SHIFT = numEnv("HUNCH_MAX_PRIOR_SHIFT", 4);
1650
+ /** Memory-record prior: a "why" question is answered by RECORDED INTENT (decisions,
1651
+ * constraints, bugs, runbooks, policies), not by the code symbols that merely share
1652
+ * its vocabulary. Symbols carry a neutral prior (priorMeta -> null), so on a graph
1653
+ * with thousands of indexed symbols a lexical tie let them occupy the whole top-k
1654
+ * and bury the one live decision — including a topic-chain successor that promotion
1655
+ * had correctly injected just below the cut line. This lifts memory records by a
1656
+ * bounded number of positions; it never EXCLUDES a kind (a symbol-name query still
1657
+ * returns symbols, and a constraint stays reachable), it only breaks the tie toward
1658
+ * intent. Measured on bench/golden-retrieval.json: Recall@10 70% -> 90%, MRR
1659
+ * 0.402 -> 0.575. Set HUNCH_MEMORY_PRIOR_SHIFT=0 to disable. */
1660
+ const MEMORY_PRIOR_SHIFT = numEnv("HUNCH_MEMORY_PRIOR_SHIFT", 12);
1661
+ const MEMORY_KINDS = new Set(["decisions", "constraints", "bugs", "runbooks", "policies"]);
1646
1662
  function numEnv(name, dflt) {
1647
1663
  const v = Number(process.env[name]);
1648
1664
  // >= 0, not > 0: zero is the documented kill-switch (HUNCH_RRF_W_*=0 disables
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.16.0",
3
+ "version": "1.17.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.16.0",
10
+ "version": "1.17.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.16.0",
16
+ "version": "1.17.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {