@davesheffer/hunch 1.7.1 → 1.8.1

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.
@@ -3,6 +3,7 @@
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { isAbsolute, resolve, join, basename, dirname } from "node:path";
5
5
  import { mkdirSync, rmSync, statSync, realpathSync, readFileSync } from "node:fs";
6
+ import { MEMLOG_FORMAT } from "../core/memorylog.js";
6
7
  function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
7
8
  // stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
8
9
  return execFileSync("git", args, {
@@ -295,6 +296,44 @@ export function commitFiles(sha, cwd) {
295
296
  const out = gitSafe(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
296
297
  return out ? out.split("\n").filter(Boolean) : [];
297
298
  }
299
+ /** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
300
+ * timeline (each commit that changed the graph). Newest first; empty on any error
301
+ * (no repo / no history), so the caller degrades to an empty timeline. */
302
+ export function gitMemoryLog(root, limit = 200) {
303
+ return gitSafe(["log", `--max-count=${limit}`, "--no-color", `--format=${MEMLOG_FORMAT}`, "--name-status", "--", ".hunch/"], root);
304
+ }
305
+ /** The diff of a single commit restricted to `.hunch/` — what one memory move
306
+ * actually changed, for the click-through popup. Empty on error. */
307
+ export function memoryMoveDiff(sha, root) {
308
+ return gitSafe(["show", "--no-color", "--format=%H%n%an%n%cI%n%s%n", sha, "--", ".hunch/"], root);
309
+ }
310
+ /** Push the current branch to its remote (the "approve-to-push" step — public
311
+ * memory rides the repo, so this is a plain branch push). Returns true on success;
312
+ * false when there is no upstream / offline / not a repo. */
313
+ export function pushCurrentBranch(root) {
314
+ try {
315
+ execFileSync("git", ["-C", root, "push"], { stdio: "ignore" });
316
+ return true;
317
+ }
318
+ catch {
319
+ return false;
320
+ }
321
+ }
322
+ /** Revert a single memory move locally (no push). Returns true on success. A
323
+ * conflicting revert is aborted so the working tree is never left half-reverted. */
324
+ export function revertMemoryMove(sha, root) {
325
+ try {
326
+ execFileSync("git", ["-C", root, "revert", "--no-edit", sha], { stdio: "ignore" });
327
+ return true;
328
+ }
329
+ catch {
330
+ try {
331
+ execFileSync("git", ["-C", root, "revert", "--abort"], { stdio: "ignore" });
332
+ }
333
+ catch { /* nothing to abort */ }
334
+ return false;
335
+ }
336
+ }
298
337
  /** Full metadata + changed files for a commit. */
299
338
  export function commitMeta(sha, cwd) {
300
339
  const raw = gitSafe(["show", "-s", "--format=%H%x1f%h%x1f%s%x1f%b%x1f%an%x1f%aI", sha], cwd);
@@ -8,14 +8,14 @@
8
8
  * then runs HunchStore.reindex() to refresh the SQLite index.
9
9
  */
10
10
  import { readFileSync, statSync, readdirSync } from "node:fs";
11
- import { join, relative, posix } from "node:path";
11
+ import { join, relative, dirname, posix } from "node:path";
12
12
  import { parseSource, attributeCalls } from "./parse.js";
13
13
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
14
14
  import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
15
15
  import { resolveRelativeImport } from "../core/relativeImports.js";
16
16
  import { extracted, inferred } from "../core/types.js";
17
17
  import { isGitRepo, trackedFiles, fileGitMetrics } from "./git.js";
18
- const CODE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
18
+ import { CODE_EXTENSIONS, languageFor } from "./languages.js";
19
19
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".hunch", "coverage", ".next", "out"]);
20
20
  export function indexRepo(store, root, opts = {}) {
21
21
  const files = listCodeFiles(root);
@@ -84,9 +84,19 @@ export function indexRepo(store, root, opts = {}) {
84
84
  perFileImports.push({ file: rel, imports: parsed.imports });
85
85
  }
86
86
  const byId = new Map(symbols.map((s) => [s.id, s]));
87
+ // Language-aware import resolution, shared by the call-resolution "was this
88
+ // name actually imported?" gate (below) and the depends_on edge derivation
89
+ // (pass 3): a Python cross-file call/import must resolve through the same
90
+ // relative/absolute Python rules as everything else, not silently fail the
91
+ // JS/TS resolver and look unimported.
92
+ const hasSrcLayout = [...fileSymbols.keys()].some((f) => f.startsWith("src/"));
93
+ const pyRoots = hasSrcLayout ? ["", "src"] : [""];
94
+ const resolveImportTarget = (file, spec) => languageFor(file)?.id === "python"
95
+ ? resolvePythonImport(file, spec, fileSymbols, pyRoots)
96
+ : resolveImport(file, spec, fileSymbols);
87
97
  const importedFiles = new Map(perFileImports.map(({ file, imports }) => [
88
98
  file,
89
- new Set(imports.map((specifier) => resolveImport(file, specifier, fileSymbols)).filter((target) => !!target)),
99
+ new Set(imports.map((specifier) => resolveImportTarget(file, specifier)).filter((target) => !!target)),
90
100
  ]));
91
101
  // ---- pass 2: resolve calls -> symbol-level edges -------------------------
92
102
  const edges = [];
@@ -151,7 +161,7 @@ export function indexRepo(store, root, opts = {}) {
151
161
  if (!fromCmp)
152
162
  continue;
153
163
  for (const spec of imports) {
154
- const target = resolveImport(file, spec, fileSymbols);
164
+ const target = resolveImportTarget(file, spec);
155
165
  if (target) {
156
166
  const toCmp = fileToComponent.get(target);
157
167
  if (!toCmp || toCmp === fromCmp)
@@ -213,7 +223,7 @@ function listCodeFiles(root) {
213
223
  if (isGitRepo(root)) {
214
224
  // Apply SKIP_DIRS to the git-tracked list too: a repo that (accidentally)
215
225
  // tracks node_modules/ or dist/ must not flood the graph with vendored symbols.
216
- const tracked = trackedFiles(root, CODE_EXTS)
226
+ const tracked = trackedFiles(root, CODE_EXTENSIONS)
217
227
  .filter((f) => !f.split(/[\\/]/).some((seg) => SKIP_DIRS.has(seg)))
218
228
  .map((f) => join(root, f));
219
229
  if (tracked.length > 0)
@@ -228,7 +238,7 @@ function listCodeFiles(root) {
228
238
  const st = statSync(abs);
229
239
  if (st.isDirectory())
230
240
  walk(abs);
231
- else if (CODE_EXTS.some((e) => name.endsWith(e)))
241
+ else if (languageFor(name) !== null)
232
242
  out.push(abs);
233
243
  }
234
244
  };
@@ -255,6 +265,55 @@ function resolveName(name, file, importedFiles, nameIndex, byId) {
255
265
  function resolveImport(fromFile, spec, fileSymbols) {
256
266
  return resolveRelativeImport(fromFile, spec, fileSymbols.keys()).path;
257
267
  }
268
+ /** First of `${modulePath}.py` / `${modulePath}/__init__.py` that's a tracked file,
269
+ * or null — the shared "module file vs. package __init__" candidate check used by
270
+ * both resolvePythonImport branches below. */
271
+ function firstExistingPyModule(modulePath, fileSymbols) {
272
+ const candidates = [`${modulePath}.py`, `${modulePath}/__init__.py`];
273
+ for (const c of candidates)
274
+ if (fileSymbols.has(c))
275
+ return c;
276
+ return null;
277
+ }
278
+ /** Resolve a Python import specifier (relative or absolute) to a concrete tracked
279
+ * file path. Sibling to resolveImport() — Python's leading dot means "N levels up
280
+ * from the importing module's own directory," not "a relative file-path fragment"
281
+ * the way JS/TS's `./`/`../` does. Absolute imports are resolved best-effort
282
+ * against `pyRoots` (repo root, plus a top-level `src/` layout if one exists) —
283
+ * no sys.path/PYTHONPATH emulation. A module's own package directory is always
284
+ * its containing directory, so relative resolution needs no repo-wide
285
+ * package-root search — only dot-counting from `fromFile`'s own location. */
286
+ function resolvePythonImport(fromFile, spec, fileSymbols, pyRoots) {
287
+ if (!spec.startsWith(".")) {
288
+ const specPath = spec.split(".").join("/");
289
+ for (const root of pyRoots) {
290
+ const modulePath = root ? `${root}/${specPath}` : specPath;
291
+ const found = firstExistingPyModule(modulePath, fileSymbols);
292
+ if (found)
293
+ return found;
294
+ }
295
+ return null;
296
+ }
297
+ const level = spec.length - spec.replace(/^\.+/, "").length;
298
+ const tail = spec.slice(level);
299
+ const dir = toPosix(dirname(fromFile));
300
+ const segments = dir === "." ? [] : dir.split("/");
301
+ const pop = level - 1;
302
+ if (pop > segments.length)
303
+ return null; // import points above the repo root — don't guess
304
+ const baseSegments = pop > 0 ? segments.slice(0, segments.length - pop) : segments;
305
+ const baseDir = baseSegments.join("/");
306
+ if (!tail) {
307
+ // bare `.`/`..`/etc — `from . import x` only ever resolves to the package's
308
+ // own __init__.py (we track the module path, never the imported name itself,
309
+ // matching resolveImport()'s granularity for JS/TS named imports).
310
+ const initPy = baseDir ? `${baseDir}/__init__.py` : "__init__.py";
311
+ return fileSymbols.has(initPy) ? initPy : null;
312
+ }
313
+ const tailPath = tail.split(".").join("/");
314
+ const modulePath = baseDir ? `${baseDir}/${tailPath}` : tailPath;
315
+ return firstExistingPyModule(modulePath, fileSymbols);
316
+ }
258
317
  /** Derive components from the directory layout: the directory immediately under
259
318
  * `src/` (or the top-level dir) groups files into a module component. */
260
319
  function deriveComponents(symbols) {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Language registry: one LanguageSpec per supported language, consumed by
3
+ * parse.ts (tree-sitter grammar/query dispatch), indexer.ts / diff.ts /
4
+ * synthesize.ts ("is this a code file?"). Adding a language is a new entry
5
+ * here (+ a new tree-sitter-* dependency), not edits scattered across those
6
+ * four files.
7
+ */
8
+ import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
9
+ const TS_QUERY = `
10
+ (function_declaration name: (identifier) @fn.name) @fn.def
11
+ (generator_function_declaration name: (identifier) @fn.name) @fn.def
12
+ (method_definition name: (property_identifier) @method.name) @method.def
13
+ (class_declaration name: (type_identifier) @class.name) @class.def
14
+ (interface_declaration name: (type_identifier) @iface.name) @iface.def
15
+ (type_alias_declaration name: (type_identifier) @type.name) @type.def
16
+ (variable_declarator
17
+ name: (identifier) @arrow.name
18
+ value: [(arrow_function) (function_expression)]) @arrow.def
19
+ (import_statement source: (string) @import.src)
20
+ (call_expression function: (identifier) @call.id)
21
+ (call_expression function: (member_expression property: (property_identifier) @call.member))
22
+ `;
23
+ const TS_BUILTIN_METHODS = new Set([
24
+ "map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
25
+ "push", "pop", "shift", "unshift", "slice", "splice", "concat", "join", "split", "flat", "flatMap",
26
+ "indexOf", "lastIndexOf", "keys", "values", "entries", "sort", "reverse", "fill", "at",
27
+ "get", "set", "has", "add", "delete", "clear",
28
+ "then", "catch", "finally", "all", "race", "resolve", "reject",
29
+ "toString", "valueOf", "toJSON", "hasOwnProperty",
30
+ "replace", "replaceAll", "trim", "trimStart", "trimEnd", "padStart", "padEnd", "startsWith", "endsWith",
31
+ "toLowerCase", "toUpperCase", "charAt", "charCodeAt", "substring", "substr", "repeat", "match", "matchAll",
32
+ "call", "apply", "bind", "test", "exec", "now", "parse", "stringify", "from", "of", "isArray", "assign",
33
+ "log", "error", "warn", "info", "debug",
34
+ ]);
35
+ const TS_SHARED = {
36
+ id: "typescript",
37
+ query: TS_QUERY,
38
+ defNodeTypes: new Set([
39
+ "function_declaration", "generator_function_declaration", "method_definition",
40
+ "class_declaration", "interface_declaration", "type_alias_declaration", "variable_declarator",
41
+ ]),
42
+ defKindOf: {
43
+ "fn.def": "function", "method.def": "method", "class.def": "class",
44
+ "iface.def": "interface", "type.def": "type", "arrow.def": "function",
45
+ },
46
+ nameToDef: {
47
+ "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def",
48
+ "iface.name": "iface.def", "type.name": "type.def", "arrow.name": "arrow.def",
49
+ },
50
+ builtinMethods: TS_BUILTIN_METHODS,
51
+ };
52
+ const TYPESCRIPT = {
53
+ ...TS_SHARED,
54
+ extensions: [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"],
55
+ grammarKey: "ts",
56
+ loadGrammar: () => loadNativeTreeSitter().typescript,
57
+ };
58
+ /** .tsx/.jsx use the TSX grammar variant; everything else in the TS spec uses
59
+ * the plain typescript grammar. Both share the same query/def maps/builtins,
60
+ * so this is a second LanguageSpec entry with a distinct grammarKey/loadGrammar
61
+ * only — not a second `id` (languageFor callers only care about extension match). */
62
+ const TSX = {
63
+ ...TS_SHARED,
64
+ extensions: [".tsx", ".jsx"],
65
+ grammarKey: "tsx",
66
+ loadGrammar: () => loadNativeTreeSitter().tsx,
67
+ };
68
+ const PY_QUERY = `
69
+ (class_definition
70
+ name: (identifier) @class.name
71
+ body: (block
72
+ [
73
+ (function_definition name: (identifier) @method.name) @method.def
74
+ (decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
75
+ ])) @class.def
76
+ (function_definition name: (identifier) @fn.name) @fn.def
77
+ (import_statement name: (dotted_name) @import.src)
78
+ (import_statement name: (aliased_import name: (dotted_name) @import.src))
79
+ (import_from_statement module_name: (dotted_name) @import.src)
80
+ (import_from_statement module_name: (relative_import) @import.src)
81
+ (call function: (identifier) @call.id)
82
+ (call function: (attribute attribute: (identifier) @call.member))
83
+ `;
84
+ const PY_BUILTIN_METHODS = new Set([
85
+ "get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy", "clear",
86
+ "append", "extend", "insert", "remove", "reverse", "sort", "count", "index",
87
+ "add", "discard", "union", "intersection", "difference",
88
+ "format", "join", "split", "rsplit", "splitlines", "strip", "lstrip", "rstrip",
89
+ "startswith", "endswith", "replace", "find", "rfind", "lower", "upper", "title", "capitalize",
90
+ "encode", "decode", "isdigit", "isalpha", "isalnum", "isspace",
91
+ "read", "write", "close", "open", "readline", "readlines",
92
+ "run", "wait", "poll", "communicate",
93
+ ]);
94
+ const PYTHON = {
95
+ id: "python",
96
+ extensions: [".py", ".pyi"],
97
+ grammarKey: "python",
98
+ loadGrammar: () => loadNativeTreeSitter().python,
99
+ query: PY_QUERY,
100
+ defNodeTypes: new Set(["function_definition", "class_definition"]),
101
+ defKindOf: { "fn.def": "function", "method.def": "method", "class.def": "class" },
102
+ nameToDef: { "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def" },
103
+ builtinMethods: PY_BUILTIN_METHODS,
104
+ };
105
+ export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON];
106
+ export const CODE_EXTENSIONS = [...new Set(LANGUAGES.flatMap((l) => l.extensions))];
107
+ export function languageFor(file) {
108
+ for (const lang of LANGUAGES) {
109
+ if (lang.extensions.some((ext) => file.endsWith(ext)))
110
+ return lang;
111
+ }
112
+ return null;
113
+ }
114
+ //# sourceMappingURL=languages.js.map
@@ -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"];
7
+ const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python"];
8
8
  let runtime = null;
9
9
  function processIsAlive(pid) {
10
10
  if (pid === process.pid)
@@ -56,14 +56,15 @@ function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
56
56
  copyFileSync(source, destination);
57
57
  return packageCopy;
58
58
  }
59
- /** Load both native tree-sitter addons from process-owned temp copies. Windows
60
- * keeps loaded `.node` files locked for the process lifetime; redirecting the
61
- * upstream loaders means npm can replace the installed package during an active
62
- * MCP session without killing that session or falling back to a stale binary. */
59
+ /** Load all native tree-sitter addons (the parser runtime + every grammar) from
60
+ * process-owned temp copies. Windows keeps loaded `.node` files locked for the
61
+ * process lifetime; redirecting the upstream loaders means npm can replace the
62
+ * installed package during an active MCP session without killing that session
63
+ * or falling back to a stale binary. */
63
64
  export function loadNativeTreeSitter() {
64
65
  if (runtime)
65
66
  return runtime;
66
- const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /tree-sitter(?:-typescript)?\.node$/.test(path)
67
+ const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /tree-sitter(?:-typescript|-python)?\.node$/.test(path)
67
68
  && !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
68
69
  if (preloaded.length) {
69
70
  throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
@@ -80,7 +81,8 @@ export function loadNativeTreeSitter() {
80
81
  }
81
82
  const Parser = runtimeRequire("tree-sitter");
82
83
  const languages = runtimeRequire("tree-sitter-typescript");
83
- runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx };
84
+ const python = runtimeRequire("tree-sitter-python");
85
+ runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python };
84
86
  }
85
87
  catch (error) {
86
88
  try {
@@ -1,62 +1,25 @@
1
+ import { languageFor } from "./languages.js";
1
2
  import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
2
- const { Parser, typescript, tsx } = loadNativeTreeSitter();
3
- /** Extremely common builtin/array/object/string/promise method names. Member
4
- * calls to these (e.g. `arr.map(...)`) must NOT create call edges to unrelated
5
- * repo symbols that happen to share the name (DESIGN: keep the graph clean). */
6
- const BUILTIN_METHODS = new Set([
7
- "map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
8
- "push", "pop", "shift", "unshift", "slice", "splice", "concat", "join", "split", "flat", "flatMap",
9
- "indexOf", "lastIndexOf", "keys", "values", "entries", "sort", "reverse", "fill", "at",
10
- "get", "set", "has", "add", "delete", "clear",
11
- "then", "catch", "finally", "all", "race", "resolve", "reject",
12
- "toString", "valueOf", "toJSON", "hasOwnProperty",
13
- "replace", "replaceAll", "trim", "trimStart", "trimEnd", "padStart", "padEnd", "startsWith", "endsWith",
14
- "toLowerCase", "toUpperCase", "charAt", "charCodeAt", "substring", "substr", "repeat", "match", "matchAll",
15
- "call", "apply", "bind", "test", "exec", "now", "parse", "stringify", "from", "of", "isArray", "assign",
16
- "log", "error", "warn", "info", "debug",
17
- ]);
18
- /** Tree-sitter query capturing every construct we care about in one pass. */
19
- const QUERY_SRC = `
20
- (function_declaration name: (identifier) @fn.name) @fn.def
21
- (generator_function_declaration name: (identifier) @fn.name) @fn.def
22
- (method_definition name: (property_identifier) @method.name) @method.def
23
- (class_declaration name: (type_identifier) @class.name) @class.def
24
- (interface_declaration name: (type_identifier) @iface.name) @iface.def
25
- (type_alias_declaration name: (type_identifier) @type.name) @type.def
26
- (variable_declarator
27
- name: (identifier) @arrow.name
28
- value: [(arrow_function) (function_expression)]) @arrow.def
29
- (import_statement source: (string) @import.src)
30
- (call_expression function: (identifier) @call.id)
31
- (call_expression function: (member_expression property: (property_identifier) @call.member))
32
- `;
3
+ const { Parser } = loadNativeTreeSitter();
33
4
  const cache = new Map();
34
- function bundleFor(lang, key) {
35
- let b = cache.get(key);
5
+ function bundleFor(spec) {
6
+ let b = cache.get(spec.grammarKey);
36
7
  if (!b) {
37
8
  const parser = new Parser();
38
- parser.setLanguage(lang);
39
- const query = new Parser.Query(lang, QUERY_SRC);
9
+ const grammar = spec.loadGrammar();
10
+ parser.setLanguage(grammar);
11
+ const query = new Parser.Query(grammar, spec.query);
40
12
  b = { parser, query };
41
- cache.set(key, b);
13
+ cache.set(spec.grammarKey, b);
42
14
  }
43
15
  return b;
44
16
  }
45
- function pickLanguage(file) {
46
- if (file.endsWith(".tsx") || file.endsWith(".jsx"))
47
- return { lang: tsx, key: "tsx" };
48
- if (file.endsWith(".ts") || file.endsWith(".mts") || file.endsWith(".cts"))
49
- return { lang: typescript, key: "ts" };
50
- if (file.endsWith(".js") || file.endsWith(".mjs") || file.endsWith(".cjs"))
51
- return { lang: typescript, key: "ts" };
52
- return null;
53
- }
54
17
  const STR_QUOTES = /^['"`]|['"`]$/g;
55
18
  export function parseSource(file, source) {
56
- const picked = pickLanguage(file);
57
- if (!picked)
19
+ const spec = languageFor(file);
20
+ if (!spec)
58
21
  return null;
59
- const { parser, query } = bundleFor(picked.lang, picked.key);
22
+ const { parser, query } = bundleFor(spec);
60
23
  // The native binding caps its scratch buffer at 32 KB unless bufferSize is
61
24
  // given — without this, any source >= 32768 bytes throws "Invalid argument"
62
25
  // and would abort the whole index run. Guard with try/catch as a backstop.
@@ -73,29 +36,27 @@ export function parseSource(file, source) {
73
36
  // group captures by their enclosing @*.def via a quick pass: we record names
74
37
  // keyed by the def node, then emit a symbol per def.
75
38
  const pendingDefs = new Map();
76
- const defKind = {
77
- "fn.def": "function", "method.def": "method", "class.def": "class",
78
- "iface.def": "interface", "type.def": "type", "arrow.def": "function",
79
- };
80
- const nameToDef = {
81
- "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def",
82
- "iface.name": "iface.def", "type.name": "type.def", "arrow.name": "arrow.def",
83
- };
84
39
  for (const cap of query.captures(tree.rootNode)) {
85
40
  const cname = cap.name;
86
41
  const node = cap.node;
87
42
  if (cname.endsWith(".def")) {
88
- pendingDefs.set(node.id, { kind: defKind[cname], def: node });
43
+ // Keep the FIRST classification a node id receives: a query may have
44
+ // several patterns matching the same node at different specificity
45
+ // (e.g. a Python method inside a class body matches both a class-nested
46
+ // "method.def" pattern and a general "fn.def" pattern — Task 4 relies on
47
+ // this to classify methods correctly without special-casing Python here).
48
+ if (!pendingDefs.has(node.id))
49
+ pendingDefs.set(node.id, { kind: spec.defKindOf[cname], def: node });
89
50
  }
90
- else if (nameToDef[cname]) {
51
+ else if (spec.nameToDef[cname]) {
91
52
  // name capture: find its parent def node id by walking up to the def type
92
- const defNode = ascendToDef(node);
53
+ const defNode = ascendToDef(node, spec.defNodeTypes);
93
54
  if (defNode) {
94
55
  const existing = pendingDefs.get(defNode.id);
95
56
  if (existing)
96
57
  existing.name = node.text;
97
58
  else
98
- pendingDefs.set(defNode.id, { kind: defKind[nameToDef[cname]], def: defNode, name: node.text });
59
+ pendingDefs.set(defNode.id, { kind: spec.defKindOf[spec.nameToDef[cname]], def: defNode, name: node.text });
99
60
  }
100
61
  }
101
62
  else if (cname === "import.src") {
@@ -106,7 +67,7 @@ export function parseSource(file, source) {
106
67
  }
107
68
  else if (cname === "call.member") {
108
69
  // skip builtin method names to avoid false edges to similarly-named symbols
109
- if (!BUILTIN_METHODS.has(node.text))
70
+ if (!spec.builtinMethods.has(node.text))
110
71
  calls.push({ callee: node.text, atByte: node.startIndex, endByte: node.endIndex, member: true });
111
72
  }
112
73
  }
@@ -123,15 +84,11 @@ export function parseSource(file, source) {
123
84
  symbols.sort((a, b) => a.startByte - b.startByte);
124
85
  return { symbols, imports, calls, parseable: !tree.rootNode.hasError };
125
86
  }
126
- /** Walk up to the nearest node whose type is a definition we recognize. */
127
- function ascendToDef(node) {
128
- const defTypes = new Set([
129
- "function_declaration", "generator_function_declaration", "method_definition",
130
- "class_declaration", "interface_declaration", "type_alias_declaration", "variable_declarator",
131
- ]);
87
+ /** Walk up to the nearest node whose type is a definition this language recognizes. */
88
+ function ascendToDef(node, defNodeTypes) {
132
89
  let cur = node.parent;
133
90
  while (cur) {
134
- if (defTypes.has(cur.type))
91
+ if (defNodeTypes.has(cur.type))
135
92
  return cur;
136
93
  cur = cur.parent;
137
94
  }
@@ -35,7 +35,8 @@ export function renderHunchSection(store, root) {
35
35
  lines.push("- `hunch_context(target_or_task)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST.**");
36
36
  lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
37
37
  lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task, before re-deriving them.");
38
- lines.push("- `hunch now` (CLI) — recent decisions + the live roadmap.");
38
+ lines.push("- `hunch_escalations()`the decisions only the HUMAN can make (topic conflicts, candidate/proposed rules, repaired rules needing a re-prove). Normally empty; when it isn't, ASK the user inline — an entry is a question, never an approval.");
39
+ lines.push("- `hunch now` (CLI) — recent decisions + the live roadmap; `hunch log` — the memory-move timeline (every capture/adopt/supersede/prune/repair, each revertable).");
39
40
  lines.push("");
40
41
  lines.push("**Before designing / choosing an approach:**");
41
42
  lines.push("- `hunch_why(target)` — why a file/symbol is shaped this way (decisions, bugs, constraints) — including what was already REJECTED.");
@@ -29,6 +29,7 @@ import { nowData, wikiStatus, publicHome, readWikiManifestAt } from "../wiki/wik
29
29
  import { HUNCH_VERSION } from "../core/version.js";
30
30
  import { indexRepo } from "../extractors/indexer.js";
31
31
  import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
32
+ import { pendingEscalations, policyEscalations } from "../core/escalations.js";
32
33
  import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
33
34
  import { randomUUID } from "node:crypto";
34
35
  const ok = (text) => ({ content: [{ type: "text", text }] });
@@ -341,7 +342,38 @@ export function buildServer(root) {
341
342
  for (const r of roadmap)
342
343
  L.push(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
343
344
  if (pendingReview > 0)
344
- L.push("", `${pendingReview} auto-drafted proposal(s) awaiting review — \`hunch review\`.`);
345
+ L.push("", `${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` auto-trusts them as advisory (new captures land trusted automatically).`);
346
+ const escalations = pendingEscalations(store.json.loadAll("decisions"));
347
+ if (escalations.length) {
348
+ L.push("", `⚖ ${escalations.length} decision(s) need the human's call — ASK inline (never queue): ${escalations.map((e) => e.question).join(" · ")}`);
349
+ }
350
+ return ok(L.join("\n"));
351
+ });
352
+ // -- hunch_escalations (the inline "ask the human" surface) -----------------
353
+ // Captured memory auto-trusts; this returns ONLY what the graph can't resolve
354
+ // itself, framed as questions to raise in conversation: topic conflicts, plus the
355
+ // Constitution's human moments (a candidate awaiting review, a proposed policy
356
+ // whose activation is a human call — §59.5.3). Public store only — same
357
+ // jurisdiction rule as hunch_now (an assistant may paste it). Client-agnostic
358
+ // (con_e04226bd05): no Claude-specific behavior.
359
+ server.registerTool("hunch_escalations", {
360
+ title: "Decisions the human must make now (ask inline, not a queue)",
361
+ description: "The rare decisions the graph cannot resolve on its own — surfaced so you ASK THE USER in the prompt at the moment, then act. Auto-captured memory is trusted automatically and never appears here; this returns topic conflicts (>1 live decision for one topic) and Constitution human moments (candidate policies awaiting review, proposed policies awaiting an activation decision). Normally empty. Raise each question with the user; do NOT decide it for them — an entry is a question, never an approval. Public store only.",
362
+ inputSchema: {},
363
+ }, async () => {
364
+ const items = pendingEscalations(store.json.loadAll("decisions"));
365
+ try {
366
+ items.push(...policyEscalations(new ConstitutionService(store, root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
367
+ }
368
+ catch { /* constitution unavailable — memory escalations still surface */ }
369
+ if (!items.length)
370
+ return ok("✓ Nothing needs a human decision — memory is auto-trusted and self-consistent.");
371
+ const L = [`${items.length} decision(s) need the human's call — ask each inline, don't decide it for them:`, ""];
372
+ for (const e of items) {
373
+ L.push(`⚖ ${e.question}`);
374
+ L.push(` ${e.detail}`);
375
+ L.push(` → ${e.resolution}`, "");
376
+ }
345
377
  return ok(L.join("\n"));
346
378
  });
347
379
  // -- hunch_wiki_status (generated-wiki freshness) ---------------------------
@@ -5,12 +5,23 @@ import { decisionId, bugId, constraintId } from "../core/ids.js";
5
5
  import { commitCoveredBy } from "../core/dupdetect.js";
6
6
  import { pathMatchesGlob } from "../core/glob.js";
7
7
  import { draftTripwires, knownRepoDeps } from "./tripwires.js";
8
- const CODE_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
9
- const SKIP_SUBJECT = /^(merge|revert|bump|chore\(deps\)|format|lint|wip)\b/i;
8
+ import { languageFor } from "../extractors/languages.js";
9
+ // "chore(deps):" is anchored separately (not via \b) because \b requires a
10
+ // word/non-word transition, and the character after the closing ")" is ":" or a
11
+ // space — both non-word — so no boundary ever fires there.
12
+ const SKIP_SUBJECT = /^(merge|revert|bump|format|lint|wip)\b|^chore\(deps\):/i;
10
13
  // Below this many changed code lines, a commit with no structural change and no
11
14
  // explanatory body isn't worth a paid LLM call. Tunable via HUNCH_SIG_MIN_LINES.
12
15
  const SIG_MIN_LINES = Number(process.env.HUNCH_SIG_MIN_LINES) || 12;
13
16
  const SIG_MIN_BODY = 40;
17
+ /** Trivial-subject commits (merge/revert/bump/format/...) are noise UNLESS the body
18
+ * carries real content — a squash/PR description often lands there, not on the
19
+ * subject. Gated on body length ALONE (not the full isSignificant() heuristic): a
20
+ * large auto-generated reformat or dependency-bump diff with no narrative must stay
21
+ * skipped even though it would trip isSignificant()'s line/file/structural checks. */
22
+ export function isTrivialSubject(meta) {
23
+ return SKIP_SUBJECT.test(meta.subject) && meta.body.trim().length < SIG_MIN_BODY;
24
+ }
14
25
  /** Is a commit substantive enough to spend a paid LLM synthesis call on? Pure and
15
26
  * deterministic. Any structural change (symbol/dependency delta), non-trivial
16
27
  * churn, several files, OR an explanatory commit body signals a real decision
@@ -37,9 +48,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
37
48
  const meta = commitMeta(target, root);
38
49
  if (!meta)
39
50
  return { status: "skipped", reason: "commit not found" };
40
- if (SKIP_SUBJECT.test(meta.subject))
51
+ if (isTrivialSubject(meta))
41
52
  return { status: "skipped", reason: `trivial subject: ${meta.subject}` };
42
- const codeFiles = meta.files.filter((f) => CODE_RE.test(f));
53
+ const codeFiles = meta.files.filter((f) => languageFor(f) !== null);
43
54
  if (codeFiles.length === 0)
44
55
  return { status: "skipped", reason: "no code files changed" };
45
56
  // Seed the id from the COMMIT (stable across runs), not the LLM-generated title
@@ -149,7 +160,14 @@ export async function syncCommit(store, root, sha, opts = {}) {
149
160
  // Auto-synthesized decisions are un-anchored (topic null) — a topic is a human
150
161
  // act, never a machine guess. Preserve one an earlier human capture attached.
151
162
  topic: existing?.topic ?? null,
152
- status: existing?.status === "accepted" ? "accepted" : "proposed",
163
+ // Auto-trust model: captured memory enters the graph LIVE (accepted = in-force
164
+ // advisory), never a `proposed` draft rotting in a review queue. It grounds and
165
+ // ranks immediately but NEVER blocks — the source stays llm_draft, so the veto /
166
+ // strict gates (which key on human_confirmed, not status) treat it as advisory
167
+ // and its tripwires stay unarmed until a human vouches INLINE (dec_a466655539
168
+ // intact). An existing human status (a deliberate `proposed` roadmap entry, or a
169
+ // `superseded` record) is preserved — re-sync never clobbers a human's intent.
170
+ status: existing?.status ?? "accepted",
153
171
  context: draft.context + constraintNote,
154
172
  decision: draft.decision,
155
173
  consequences: draft.consequences,