@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.
- package/README.md +30 -0
- package/dist/cli/index.js +353 -9
- package/dist/constitution/experiment.js +60 -1
- package/dist/constitution/g3Conformance.js +26 -9
- package/dist/constitution/lifecycle.js +35 -0
- package/dist/constitution/repairPolicies.js +78 -0
- package/dist/constitution/schema.js +1 -1
- package/dist/constitution/service.js +72 -10
- package/dist/core/escalations.js +65 -0
- package/dist/core/memorylog.js +69 -0
- package/dist/core/repair.js +71 -0
- package/dist/core/reviewqueue.js +11 -0
- package/dist/extractors/diff.js +19 -3
- package/dist/extractors/git.js +39 -0
- package/dist/extractors/indexer.js +65 -6
- package/dist/extractors/languages.js +114 -0
- package/dist/extractors/nativeTreeSitter.js +9 -7
- package/dist/extractors/parse.js +25 -68
- package/dist/integrations/claudemd.js +2 -1
- package/dist/mcp/server.js +33 -1
- package/dist/synthesis/synthesize.js +23 -5
- package/dist/wiki/graph.js +301 -0
- package/dist/wiki/wiki.js +31 -3
- package/package.json +2 -1
package/dist/extractors/git.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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 =
|
|
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,
|
|
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 (
|
|
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
|
|
60
|
-
* keeps loaded `.node` files locked for the
|
|
61
|
-
* upstream loaders means npm can replace the
|
|
62
|
-
* MCP session without killing that session
|
|
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
|
-
|
|
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 {
|
package/dist/extractors/parse.js
CHANGED
|
@@ -1,62 +1,25 @@
|
|
|
1
|
+
import { languageFor } from "./languages.js";
|
|
1
2
|
import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
|
|
2
|
-
const { Parser
|
|
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(
|
|
35
|
-
let b = cache.get(
|
|
5
|
+
function bundleFor(spec) {
|
|
6
|
+
let b = cache.get(spec.grammarKey);
|
|
36
7
|
if (!b) {
|
|
37
8
|
const parser = new Parser();
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
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
|
|
57
|
-
if (!
|
|
19
|
+
const spec = languageFor(file);
|
|
20
|
+
if (!spec)
|
|
58
21
|
return null;
|
|
59
|
-
const { parser, query } = bundleFor(
|
|
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
|
-
|
|
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:
|
|
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 (!
|
|
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
|
|
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 (
|
|
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("- `
|
|
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.");
|
package/dist/mcp/server.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
9
|
-
|
|
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 (
|
|
51
|
+
if (isTrivialSubject(meta))
|
|
41
52
|
return { status: "skipped", reason: `trivial subject: ${meta.subject}` };
|
|
42
|
-
const codeFiles = meta.files.filter((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
|
-
|
|
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,
|