@davesheffer/hunch 1.17.0 → 1.18.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.
@@ -15,10 +15,27 @@ function bundleFor(spec) {
15
15
  return b;
16
16
  }
17
17
  const STR_QUOTES = /^['"`]|['"`]$/g;
18
+ /** Cap on a stored symbol's bodyText — large enough for review context, small
19
+ * enough that a huge function/file doesn't bloat every JSON symbol record. */
20
+ export const MAX_BODY_TEXT_CHARS = 4000;
18
21
  export function parseSource(file, source) {
19
22
  const spec = languageFor(file);
20
23
  if (!spec)
21
24
  return null;
25
+ // Templated text (Helm chart / Jinja CI config) isn't {spec.id} yet — a real
26
+ // grammar correctly reports ERROR nodes for the delimiters. Still run the
27
+ // parse below (a well-formed anchor elsewhere in the file still contributes
28
+ // a real symbol, same as any other YAML file) — just don't let those
29
+ // expected errors fail-close the whole-repo scan on content that was never
30
+ // meant to stand alone (#33). Heavy top-level templating can break error
31
+ // recovery badly enough that even the whole-file root node never forms
32
+ // (root.type becomes "ERROR", not "stream") — the fallback-symbol synthesis
33
+ // below covers that case so the file doesn't vanish from the component graph.
34
+ // String.prototype.search ignores lastIndex (unlike RegExp.test with a /g or
35
+ // /y flag), so a future templatingMarkers entry can't introduce cross-call
36
+ // statefulness here even if it forgets to keep its pattern flag-free.
37
+ const templated = (spec.alwaysTemplatedExtensions?.some((ext) => file.endsWith(ext)) ?? false)
38
+ || (spec.templatingMarkers?.some((marker) => source.search(marker) !== -1) ?? false);
22
39
  const { parser, query } = bundleFor(spec);
23
40
  // The native binding caps its scratch buffer at 32 KB unless bufferSize is
24
41
  // given — without this, any source >= 32768 bytes throws "Invalid argument"
@@ -72,17 +89,34 @@ export function parseSource(file, source) {
72
89
  }
73
90
  }
74
91
  for (const { kind, def, name } of pendingDefs.values()) {
75
- if (!name)
92
+ const resolvedName = name ?? spec.fallbackDefName?.(file);
93
+ if (!resolvedName)
76
94
  continue;
77
95
  const loc = def.endPosition.row - def.startPosition.row + 1;
78
96
  symbols.push({
79
- name, kind,
97
+ name: resolvedName, kind,
80
98
  startByte: def.startIndex, endByte: def.endIndex, loc,
81
- bodyText: def.text.slice(0, 4000),
99
+ bodyText: def.text.slice(0, MAX_BODY_TEXT_CHARS),
100
+ });
101
+ }
102
+ // Every other successfully-parsed YAML file gets at least a file-root symbol
103
+ // (fallbackDefName). If templating broke error recovery badly enough that
104
+ // the doc.def capture never fired, synthesize the same fallback here rather
105
+ // than let the file silently drop out of the component graph. Push it before
106
+ // the sort below — parse()'s callers (indexer.ts) rely on symbols staying in
107
+ // start-byte order.
108
+ if (templated && spec.fallbackDefName && !symbols.some((s) => s.kind === "file")) {
109
+ symbols.push({
110
+ name: spec.fallbackDefName(file),
111
+ kind: "file",
112
+ startByte: 0,
113
+ endByte: source.length,
114
+ loc: source.split("\n").length,
115
+ bodyText: source.slice(0, MAX_BODY_TEXT_CHARS),
82
116
  });
83
117
  }
84
118
  symbols.sort((a, b) => a.startByte - b.startByte);
85
- return { symbols, imports, calls, parseable: isParseable(tree.rootNode, spec) };
119
+ return { symbols, imports, calls, parseable: templated || isParseable(tree.rootNode, spec) };
86
120
  }
87
121
  /** True when every ERROR/MISSING node in the tree sits in an ancestor shape this
88
122
  * language declares as a known grammar limitation (LanguageSpec.toleratedErrorScopes).
@@ -137,25 +171,35 @@ function ascendToDef(node, defNodeTypes) {
137
171
  return null;
138
172
  }
139
173
  /** Map each call site to the innermost symbol whose byte-range contains it.
140
- * Keyed by the symbol's `startByte` (a stable per-symbol identity within the
141
- * file) rather than its name, so two same-named symbols in one file don't merge
142
- * their call sets. The value maps callee name -> `memberOnly` (true iff every
143
- * occurrence was a `x.foo()` member call, never a direct `foo()`), so the
144
- * indexer can resolve member calls conservatively. */
174
+ * Keyed by the symbol's position (index) in `parsed.symbols` NOT its
175
+ * startByte, which is not a reliable per-symbol identity: a language whose
176
+ * extractor merges a synthetic whole-file symbol with independently-derived
177
+ * symbols (e.g. YAML's fallback-root synthetic symbol alongside Helm's
178
+ * regex-derived `define` blocks) can produce two distinct symbols that both
179
+ * start at byte 0. Indexing by array position is unique by construction,
180
+ * regardless of byte overlap — the caller must consume the exact same
181
+ * `parsed.symbols` array (or an equivalently-ordered copy) to look up a
182
+ * symbol by the index this function returns. The value maps callee name ->
183
+ * `memberOnly` (true iff every occurrence was a `x.foo()` member call, never
184
+ * a direct `foo()`), so the indexer can resolve member calls conservatively. */
145
185
  export function attributeCalls(parsed) {
146
186
  const out = new Map();
147
187
  for (const call of parsed.calls) {
148
188
  let best = null;
149
- for (const s of parsed.symbols) {
189
+ let bestIndex = -1;
190
+ for (let i = 0; i < parsed.symbols.length; i++) {
191
+ const s = parsed.symbols[i];
150
192
  if (call.atByte >= s.startByte && call.atByte < s.endByte) {
151
- if (!best || s.endByte - s.startByte < best.endByte - best.startByte)
193
+ if (!best || s.endByte - s.startByte < best.endByte - best.startByte) {
152
194
  best = s;
195
+ bestIndex = i;
196
+ }
153
197
  }
154
198
  }
155
199
  if (best && best.name !== call.callee) {
156
- if (!out.has(best.startByte))
157
- out.set(best.startByte, new Map());
158
- const m = out.get(best.startByte);
200
+ if (!out.has(bestIndex))
201
+ out.set(bestIndex, new Map());
202
+ const m = out.get(bestIndex);
159
203
  const prev = m.get(call.callee);
160
204
  m.set(call.callee, prev === undefined ? call.member : prev && call.member);
161
205
  }
@@ -73,10 +73,21 @@ const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pus
73
73
  * lands in the committed store publishes on the next push, and an agent writing
74
74
  * strategy/competitive content there is a leak nobody notices until it ships
75
75
  * (2026-08-09: 15 roadmap records caught pre-push only by a release sweep). */
76
- /** Repo-local term list, read once per server process. The package ships none;
77
- * `.hunch/publication.json` is how a repo opts in (see src/core/publication.ts). */
78
- let vocabularyCache = null;
79
- const publicationVocabulary = (hunchDir) => (vocabularyCache ??= loadVocabulary(hunchDir));
76
+ /** Repo-local term list, read once per STORE. The package ships none;
77
+ * `.hunch/publication.json` is how a repo opts in (see src/core/publication.ts).
78
+ * Keyed by hunchDir: the previous scalar memoized the FIRST store's vocabulary
79
+ * and served it to every store in the process, so a multi-store consumer would
80
+ * leak-scan project B's records against project A's terms — a wrong answer in
81
+ * the exact subsystem whose job is catching leaks (GATE-P1-UPSTREAM.md). */
82
+ const vocabularyCache = new Map();
83
+ export const publicationVocabulary = (hunchDir) => {
84
+ let vocab = vocabularyCache.get(hunchDir);
85
+ if (!vocab) {
86
+ vocab = loadVocabulary(hunchDir);
87
+ vocabularyCache.set(hunchDir, vocab);
88
+ }
89
+ return vocab;
90
+ };
80
91
  const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
81
92
  if (home !== "public")
82
93
  return "";
@@ -280,6 +291,9 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
280
291
  ensureTeamOverlay(root);
281
292
  const store = new HunchStore(hunchPaths(root));
282
293
  try {
294
+ const overlayWarning = store.overlayResolutionWarning(explicitOverlay && existsSync(teamFile));
295
+ if (overlayWarning)
296
+ console.error(`[hunch-mcp] ⚠ ${overlayWarning}`);
283
297
  if (teamAdvertised && (store.mode !== "shared"
284
298
  || !store.privateDir
285
299
  || !existsSync(store.privateDir)
@@ -55,6 +55,13 @@ export class HunchStore {
55
55
  /** The resolved private-overlay hunch dir (from env or .hunch/local.json), or undefined
56
56
  * when no overlay is configured. Surfaced so `hunch doctor` reflects the true state. */
57
57
  privateDir;
58
+ /** How privateDir was selected. Multi-store consumers can use this instead of
59
+ * inferring process-global routing from process.env. */
60
+ overlaySource;
61
+ /** Present only when HUNCH_PRIVATE_DIR redirects this store away from the
62
+ * repo/worktree-local pointer. Precedence is compatibility-sensitive and stays
63
+ * env-first; making the redirection queryable removes the silent footgun. */
64
+ overlayOverride;
58
65
  /** Whether captures auto-commit the store they land in — ON by default in EVERY mode;
59
66
  * `--no-auto-commit` (hunch init/private/shared) persists `autoCommit: false` in
60
67
  * local.json to opt out. Read by the MCP write tools and `hunch sync`. */
@@ -84,7 +91,36 @@ export class HunchStore {
84
91
  // local config (.hunch/local.json) so `hunch private` enables it with NO env var, and
85
92
  // the MCP server / hook pick it up automatically. Relative paths resolve from root.
86
93
  const local = this.localConfig();
87
- const priv = process.env.HUNCH_PRIVATE_DIR?.trim() || local.privateDir;
94
+ const environmentDir = process.env.HUNCH_PRIVATE_DIR?.trim();
95
+ const configuredDir = local.privateDir
96
+ ? resolve(this.paths.root, local.privateDir)
97
+ : undefined;
98
+ const resolvedEnvironmentDir = environmentDir
99
+ ? resolve(this.paths.root, environmentDir)
100
+ : undefined;
101
+ const priv = resolvedEnvironmentDir || configuredDir;
102
+ this.overlaySource = resolvedEnvironmentDir
103
+ ? "environment"
104
+ : configuredDir
105
+ ? "local-config"
106
+ : null;
107
+ if (resolvedEnvironmentDir && configuredDir) {
108
+ const canonical = (path) => {
109
+ try {
110
+ return realpathSync(path);
111
+ }
112
+ catch {
113
+ return resolve(path);
114
+ }
115
+ };
116
+ const comparable = (path) => process.platform === "win32" ? canonical(path).toLowerCase() : canonical(path);
117
+ if (comparable(resolvedEnvironmentDir) !== comparable(configuredDir)) {
118
+ this.overlayOverride = {
119
+ configuredDir: canonical(configuredDir),
120
+ environmentDir: canonical(resolvedEnvironmentDir),
121
+ };
122
+ }
123
+ }
88
124
  if (priv) {
89
125
  const candidate = resolve(this.paths.root, priv);
90
126
  const canonical = (path) => { try {
@@ -118,6 +154,21 @@ export class HunchStore {
118
154
  this.mode = priv ? (local.mode ?? "private") : "public";
119
155
  this.unified = this.mode === "shared" && !!this.privateJson;
120
156
  }
157
+ /** Human-facing warning for the compatibility-preserving env-first resolution.
158
+ * Callers decide where it is safe to emit (CLI/MCP stderr, doctor output); the
159
+ * store constructor stays side-effect-free for hooks and embedded consumers. */
160
+ overlayResolutionWarning(teamConfigBypassed = false) {
161
+ if (this.overlayOverride) {
162
+ const effect = this.mode === "shared"
163
+ ? "all memory reads and captures in this process use the environment path"
164
+ : "private-overlay reads and private captures in this process use the environment path; public captures remain in the repo";
165
+ return `HUNCH_PRIVATE_DIR redirects this repo from its configured ${this.mode} memory at ${this.overlayOverride.configuredDir} to ${this.overlayOverride.environmentDir}; ${effect} (routing mode remains ${this.mode}). Unset HUNCH_PRIVATE_DIR to use the configured store.`;
166
+ }
167
+ if (this.overlaySource === "environment" && teamConfigBypassed && this.privateDir) {
168
+ return `HUNCH_PRIVATE_DIR bypasses .hunch/team.json and selects ${this.privateDir} in ${this.mode} mode; team-store auto-discovery is disabled for this process. Unset HUNCH_PRIVATE_DIR to use the advertised team store.`;
169
+ }
170
+ return null;
171
+ }
121
172
  /** Where a capture belongs: an explicit private:true always goes to the overlay
122
173
  * (putPrivate throws rather than silently landing public when none is configured);
123
174
  * otherwise the overlay in unified ("shared") mode, else the public store. ONE home
@@ -313,11 +364,35 @@ export class HunchStore {
313
364
  fts(c.id, "components", c.name, `${c.responsibility} ${c.paths.join(" ")}`);
314
365
  }
315
366
  counts.components = comps.length;
367
+ const resources = this.recs("resources");
368
+ const insResource = db.prepare(`INSERT INTO resources VALUES (@id,@schema,@kind,@name,@scope,@locator,@lifecycle,@criticality,@contract_version,@currentness,@metadata,@ps,@pc,@pe,@created_at,@updated_at)`);
369
+ for (const resource of resources) {
370
+ insResource.run({
371
+ id: resource.id, schema: resource.schema, kind: resource.kind, name: resource.name,
372
+ scope: JSON.stringify(resource.scope), locator: resource.locator, lifecycle: resource.lifecycle,
373
+ criticality: resource.criticality ?? null, contract_version: resource.contract_version ?? null,
374
+ currentness: JSON.stringify(resource.currentness), metadata: JSON.stringify(resource.metadata),
375
+ ps: resource.provenance.source, pc: resource.provenance.confidence,
376
+ pe: JSON.stringify(resource.provenance.evidence), created_at: resource.created_at, updated_at: resource.updated_at,
377
+ });
378
+ fts(resource.id, "resources", resource.name, `${resource.kind} ${resource.scope.join(" ")} ${resource.locator ?? ""} ${resource.lifecycle} ${resource.contract_version ?? ""}`);
379
+ }
380
+ counts.resources = resources.length;
316
381
  const edges = this.recs("edges");
317
382
  const insEdge = db.prepare(`INSERT INTO edges VALUES (@id,@from,@to,@type,@reason,@strength,@ps,@pc,@pe)`);
383
+ const insResourceRelationship = db.prepare(`INSERT INTO resource_relationships VALUES (@id,@schema,@from,@to,@type,@reason,@strength,@currentness,@environment,@criticality,@contract_version,@metadata,@ps,@pc,@pe)`);
318
384
  for (const e of edges) {
319
385
  insEdge.run({ id: e.id, from: e.from, to: e.to, type: e.type, reason: e.reason, strength: e.strength,
320
386
  ps: e.provenance.source, pc: e.provenance.confidence, pe: JSON.stringify(e.provenance.evidence) });
387
+ if (e.schema === "hunch.resource-relationship/1") {
388
+ insResourceRelationship.run({
389
+ id: e.id, schema: e.schema, from: e.from, to: e.to, type: e.type, reason: e.reason,
390
+ strength: e.strength, currentness: JSON.stringify(e.currentness), environment: e.environment,
391
+ criticality: e.criticality ?? null, contract_version: e.contract_version ?? null,
392
+ metadata: JSON.stringify(e.metadata), ps: e.provenance.source, pc: e.provenance.confidence,
393
+ pe: JSON.stringify(e.provenance.evidence),
394
+ });
395
+ }
321
396
  }
322
397
  counts.edges = edges.length;
323
398
  const syms = this.recs("symbols");
@@ -1324,6 +1399,7 @@ export class HunchStore {
1324
1399
  };
1325
1400
  json.put("decisions", closed);
1326
1401
  const edge = {
1402
+ schema: "hunch.edge/1",
1327
1403
  id: edgeId(by.id, oldId, "supersedes"),
1328
1404
  from: by.id,
1329
1405
  to: oldId,
@@ -1331,6 +1407,8 @@ export class HunchStore {
1331
1407
  reason: `${by.id} supersedes ${oldId}`,
1332
1408
  strength: 1,
1333
1409
  provenance: { source: "derived", confidence: 1, evidence: [by.id, oldId] },
1410
+ environment: null,
1411
+ metadata: {},
1334
1412
  };
1335
1413
  json.put("edges", edge);
1336
1414
  return closed;
@@ -12,7 +12,14 @@ import { writeFileAtomic } from "../core/io.js";
12
12
  * index.json array — there can be thousands, and one file per edge would create
13
13
  * enormous git noise. Curated, low-volume entities (components, decisions, bugs,
14
14
  * constraints) are one file per record so they're cleanly reviewable in PRs. */
15
- const SINGLE_FILE = { symbols: "index.json", edges: "index.json" };
15
+ const SINGLE_FILE = {
16
+ symbols: "index.json",
17
+ edges: "index.json",
18
+ // Resource ids remain readable kind-qualified identities (and may contain '/'),
19
+ // so the canonical array avoids lossy filename encoding while keeping Git diffs
20
+ // deterministic through id sorting.
21
+ resources: "index.json",
22
+ };
16
23
  const encode = (v) => JSON.stringify(v, null, 2) + "\n";
17
24
  // Sleep primitive for the single-file RMW lock's bounded spin (issue #35);
18
25
  // same idiom as core/io.ts's rename backoff.
@@ -29,6 +29,17 @@ CREATE TABLE IF NOT EXISTS components (
29
29
  created_at TEXT, updated_at TEXT
30
30
  );
31
31
 
32
+ CREATE TABLE IF NOT EXISTS resources (
33
+ id TEXT PRIMARY KEY,
34
+ schema TEXT, kind TEXT, name TEXT, scope TEXT, locator TEXT,
35
+ lifecycle TEXT, criticality TEXT, contract_version TEXT,
36
+ currentness TEXT, metadata TEXT,
37
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
38
+ created_at TEXT, updated_at TEXT
39
+ );
40
+ CREATE INDEX IF NOT EXISTS idx_resources_kind ON resources(kind);
41
+ CREATE INDEX IF NOT EXISTS idx_resources_lifecycle ON resources(lifecycle);
42
+
32
43
  CREATE TABLE IF NOT EXISTS edges (
33
44
  id TEXT PRIMARY KEY,
34
45
  "from" TEXT, "to" TEXT, type TEXT, reason TEXT, strength REAL,
@@ -38,6 +49,18 @@ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges("from");
38
49
  CREATE INDEX IF NOT EXISTS idx_edges_to ON edges("to");
39
50
  CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
40
51
 
52
+ -- A rebuildable projection over the subset of the existing edge graph carrying
53
+ -- the resource-relationship contract. JSON edges remain the one authority.
54
+ CREATE TABLE IF NOT EXISTS resource_relationships (
55
+ id TEXT PRIMARY KEY,
56
+ schema TEXT, "from" TEXT, "to" TEXT, type TEXT, reason TEXT, strength REAL,
57
+ currentness TEXT, environment TEXT, criticality TEXT, contract_version TEXT,
58
+ metadata TEXT, prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_resource_relationships_from ON resource_relationships("from");
61
+ CREATE INDEX IF NOT EXISTS idx_resource_relationships_to ON resource_relationships("to");
62
+ CREATE INDEX IF NOT EXISTS idx_resource_relationships_type ON resource_relationships(type);
63
+
41
64
  CREATE TABLE IF NOT EXISTS symbols (
42
65
  id TEXT PRIMARY KEY,
43
66
  file TEXT, name TEXT, kind TEXT, signature_hash TEXT,
@@ -89,7 +112,7 @@ CREATE TABLE IF NOT EXISTS embeddings (
89
112
  export const FTS_SEARCH_SCHEMA_SQL = /* sql */ `
90
113
  CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
91
114
  ref UNINDEXED, -- entity id
92
- kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints | runbooks | findings
115
+ kind UNINDEXED, -- components | resources | edges | symbols | decisions | bugs | constraints | runbooks | findings
93
116
  title,
94
117
  body,
95
118
  tokenize = 'porter unicode61'
@@ -110,7 +133,7 @@ CREATE INDEX IF NOT EXISTS idx_search_kind ON search(kind);
110
133
  /** Drop derived data (used before a full reindex). NOTE: embeddings is omitted on
111
134
  * purpose — see the embeddings table comment above. */
112
135
  export const RESET_SQL = /* sql */ `
113
- DELETE FROM components; DELETE FROM edges; DELETE FROM symbols;
136
+ DELETE FROM components; DELETE FROM resources; DELETE FROM edges; DELETE FROM resource_relationships; DELETE FROM symbols;
114
137
  DELETE FROM decisions; DELETE FROM bugs; DELETE FROM constraints;
115
138
  DELETE FROM search;
116
139
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
@@ -71,11 +71,12 @@
71
71
  "prepublishOnly": "npm run build"
72
72
  },
73
73
  "dependencies": {
74
- "@modelcontextprotocol/sdk": "^1.29.0",
74
+ "@modelcontextprotocol/sdk": "^1.30.0",
75
+ "@tree-sitter-grammars/tree-sitter-yaml": "^0.6.1",
75
76
  "commander": "^15.0.0",
76
77
  "tree-sitter": "0.21.1",
77
78
  "tree-sitter-go": "^0.23.4",
78
- "tree-sitter-python": "^0.23.2",
79
+ "tree-sitter-python": "0.23.4",
79
80
  "tree-sitter-typescript": "^0.23.2",
80
81
  "zod": "^4.4.3"
81
82
  },
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.17.0",
10
+ "version": "1.18.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.17.0",
16
+ "version": "1.18.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {