@davesheffer/hunch 1.13.1 → 1.15.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/dist/cli/index.js +10 -6
- package/dist/eval/harness.js +18 -1
- package/dist/extractors/indexer.js +53 -4
- package/dist/extractors/languages.js +49 -1
- package/dist/extractors/nativeTreeSitter.js +4 -3
- package/dist/integrations/claudemd.js +1 -0
- package/dist/mcp/server.js +77 -7
- package/dist/store/hunchStore.js +97 -29
- package/package.json +2 -1
- package/server.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -64,7 +64,7 @@ import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, st
|
|
|
64
64
|
import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
|
|
65
65
|
import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
|
|
66
66
|
import { planAutoReview, planMutations } from "../core/autoreview.js";
|
|
67
|
-
import { loadGoldenSet,
|
|
67
|
+
import { loadGoldenSet, evaluateRetrieval, evaluateTraversalLift } from "../eval/harness.js";
|
|
68
68
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
69
69
|
import { computeDrift } from "../core/drift.js";
|
|
70
70
|
import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
|
|
@@ -1349,16 +1349,20 @@ program
|
|
|
1349
1349
|
// Default is deterministic (FTS + graph, no model). --semantic only adds the
|
|
1350
1350
|
// semantic leg when embeddings actually exist; otherwise it's still FTS + graph.
|
|
1351
1351
|
const embedder = opts.semantic ? await selectEmbedder() : undefined;
|
|
1352
|
-
const
|
|
1352
|
+
const evalOpts = { k, embedder, kind: opts.kind };
|
|
1353
|
+
const off = await evaluateRetrieval(store, cases, { ...evalOpts, graphWeight: 0 });
|
|
1354
|
+
const traversal = await evaluateTraversalLift(store, cases, evalOpts);
|
|
1353
1355
|
const pct = (x) => `${(x * 100).toFixed(1)}%`;
|
|
1354
1356
|
const dpt = (x) => `${x >= 0 ? "+" : ""}${(x * 100).toFixed(1)}pt`;
|
|
1355
1357
|
const dnum = (x) => `${x >= 0 ? "+" : ""}${x.toFixed(3)}`;
|
|
1356
1358
|
console.log(`Eval over ${cases.length} case(s), k=${k}${opts.semantic ? " (semantic + graph + FTS)" : " (FTS + graph)"}\n`);
|
|
1357
1359
|
console.log(` Recall@${k} MRR hit-rate`);
|
|
1358
|
-
console.log(` graph OFF ${pct(
|
|
1359
|
-
console.log(` graph
|
|
1360
|
-
console.log(` graph
|
|
1361
|
-
|
|
1360
|
+
console.log(` graph OFF ${pct(off.recallAtK).padStart(7)} ${off.mrr.toFixed(3)} ${pct(off.hitRate)}`);
|
|
1361
|
+
console.log(` graph 1-HOP ${pct(traversal.oneHop.recallAtK).padStart(7)} ${traversal.oneHop.mrr.toFixed(3)} ${pct(traversal.oneHop.hitRate)}`);
|
|
1362
|
+
console.log(` graph BOUNDED ${pct(traversal.bounded.recallAtK).padStart(7)} ${traversal.bounded.mrr.toFixed(3)} ${pct(traversal.bounded.hitRate)}`);
|
|
1363
|
+
console.log(` graph LIFT ${dpt(traversal.bounded.recallAtK - off.recallAtK).padStart(7)} ${dnum(traversal.bounded.mrr - off.mrr)}`);
|
|
1364
|
+
console.log(` depth LIFT ${dpt(traversal.recallDelta).padStart(7)} ${dnum(traversal.mrrDelta)}`);
|
|
1365
|
+
const misses = traversal.bounded.perCase.filter((c) => c.found === 0);
|
|
1362
1366
|
if (misses.length) {
|
|
1363
1367
|
console.log(`\n ${misses.length} case(s) with no expected hit — curate or tune:`);
|
|
1364
1368
|
for (const m of misses.slice(0, 10))
|
package/dist/eval/harness.js
CHANGED
|
@@ -8,7 +8,13 @@ export async function evaluateRetrieval(store, cases, opts = {}) {
|
|
|
8
8
|
// buries terse records before any filter.
|
|
9
9
|
const hits = opts.kind
|
|
10
10
|
? await store.searchScoped(c.query, opts.kind, k, { embedder: opts.embedder })
|
|
11
|
-
: await store.hybridSearch(c.query, k, {
|
|
11
|
+
: await store.hybridSearch(c.query, k, {
|
|
12
|
+
embedder: opts.embedder,
|
|
13
|
+
graphWeight: opts.graphWeight,
|
|
14
|
+
graphDepth: opts.graphDepth,
|
|
15
|
+
graphNodeCap: opts.graphNodeCap,
|
|
16
|
+
graphTokenCap: opts.graphTokenCap,
|
|
17
|
+
});
|
|
12
18
|
const top = hits.slice(0, k).map((h) => h.ref);
|
|
13
19
|
const expected = new Set(c.expected);
|
|
14
20
|
let found = 0;
|
|
@@ -46,6 +52,17 @@ export async function evaluateGraphLift(store, cases, opts = {}) {
|
|
|
46
52
|
const on = await evaluateRetrieval(store, cases, opts);
|
|
47
53
|
return { off, on, recallDelta: on.recallAtK - off.recallAtK, mrrDelta: on.mrr - off.mrr };
|
|
48
54
|
}
|
|
55
|
+
/** Compare the historical 1-hop graph with the shipped bounded traversal. */
|
|
56
|
+
export async function evaluateTraversalLift(store, cases, opts = {}) {
|
|
57
|
+
const oneHop = await evaluateRetrieval(store, cases, { ...opts, graphDepth: 1 });
|
|
58
|
+
const bounded = await evaluateRetrieval(store, cases, opts);
|
|
59
|
+
return {
|
|
60
|
+
oneHop,
|
|
61
|
+
bounded,
|
|
62
|
+
recallDelta: bounded.recallAtK - oneHop.recallAtK,
|
|
63
|
+
mrrDelta: bounded.mrr - oneHop.mrr,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
49
66
|
/** Parse + validate a golden-set JSON string (array of {query, expected[]}). */
|
|
50
67
|
export function loadGoldenSet(raw) {
|
|
51
68
|
const data = JSON.parse(raw);
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* `indexRepo` persists that exact scan into the JSON source of truth; its caller
|
|
9
9
|
* then runs HunchStore.reindex() to refresh the SQLite index.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join, posix } from "node:path";
|
|
12
13
|
import { parseSource, attributeCalls } from "./parse.js";
|
|
13
14
|
import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
|
|
14
15
|
import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
|
|
@@ -135,9 +136,15 @@ export function scanRepo(store, root, opts = {}) {
|
|
|
135
136
|
// JS/TS resolver and look unimported.
|
|
136
137
|
const hasSrcLayout = [...fileSymbols.keys()].some((f) => f.startsWith("src/"));
|
|
137
138
|
const pyRoots = hasSrcLayout ? ["", "src"] : [""];
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
const goModule = readGoModulePath(root);
|
|
140
|
+
const resolveImportTarget = (file, spec) => {
|
|
141
|
+
const langId = languageFor(file)?.id;
|
|
142
|
+
if (langId === "python")
|
|
143
|
+
return resolvePythonImport(file, spec, fileSymbols, pyRoots);
|
|
144
|
+
if (langId === "go")
|
|
145
|
+
return resolveGoImport(spec, fileSymbols, goModule);
|
|
146
|
+
return resolveImport(file, spec, fileSymbols);
|
|
147
|
+
};
|
|
141
148
|
const importedFiles = new Map(perFileImports.map(({ file, imports }) => [
|
|
142
149
|
file,
|
|
143
150
|
new Set(imports.map((specifier) => resolveImportTarget(file, specifier)).filter((target) => !!target)),
|
|
@@ -353,6 +360,48 @@ function resolvePythonImport(fromFile, spec, fileSymbols, pyRoots) {
|
|
|
353
360
|
const modulePath = baseDir ? `${baseDir}/${tailPath}` : tailPath;
|
|
354
361
|
return firstExistingPyModule(modulePath, fileSymbols);
|
|
355
362
|
}
|
|
363
|
+
/** The `module` path declared in the repo's go.mod, or null. A resolution HINT
|
|
364
|
+
* only (it widens depends_on edge coverage); reading it best-effort from the
|
|
365
|
+
* filesystem never gates a scan. */
|
|
366
|
+
function readGoModulePath(root) {
|
|
367
|
+
try {
|
|
368
|
+
const match = /^module\s+(\S+)/m.exec(readFileSync(join(root, "go.mod"), "utf8"));
|
|
369
|
+
return match ? match[1] : null;
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Lexicographically-first tracked .go file whose directory is exactly `dir`
|
|
376
|
+
* ("" = repo root) — a Go import names a PACKAGE (directory), so any file in it
|
|
377
|
+
* identifies the right component for a depends_on edge. */
|
|
378
|
+
function firstGoFileInDir(dir, fileSymbols) {
|
|
379
|
+
let best = null;
|
|
380
|
+
for (const f of fileSymbols.keys()) {
|
|
381
|
+
if (!f.endsWith(".go"))
|
|
382
|
+
continue;
|
|
383
|
+
const d = toPosix(dirname(f));
|
|
384
|
+
const matches = dir === "" ? d === "." : d === dir;
|
|
385
|
+
if (matches && (!best || f < best))
|
|
386
|
+
best = f;
|
|
387
|
+
}
|
|
388
|
+
return best;
|
|
389
|
+
}
|
|
390
|
+
/** Resolve a Go import path to a tracked file. Sibling to resolvePythonImport():
|
|
391
|
+
* an in-module import is the go.mod module path plus the package directory, so
|
|
392
|
+
* strip the declared module prefix and look the directory up exactly; with no
|
|
393
|
+
* go.mod, try the path as a repo-relative directory. Anything else (stdlib,
|
|
394
|
+
* external modules) resolves to null — no suffix guessing, a wrong depends_on
|
|
395
|
+
* edge is worse than a missing one. */
|
|
396
|
+
function resolveGoImport(spec, fileSymbols, goModule) {
|
|
397
|
+
if (goModule) {
|
|
398
|
+
if (spec === goModule)
|
|
399
|
+
return firstGoFileInDir("", fileSymbols);
|
|
400
|
+
if (spec.startsWith(`${goModule}/`))
|
|
401
|
+
return firstGoFileInDir(spec.slice(goModule.length + 1), fileSymbols);
|
|
402
|
+
}
|
|
403
|
+
return firstGoFileInDir(spec, fileSymbols);
|
|
404
|
+
}
|
|
356
405
|
/** Derive components from the directory layout: the directory immediately under
|
|
357
406
|
* `src/` (or the top-level dir) groups files into a module component. */
|
|
358
407
|
function deriveComponents(symbols) {
|
|
@@ -125,7 +125,55 @@ const PYTHON = {
|
|
|
125
125
|
nameToDef: { "fn.name": "fn.def", "method.name": "method.def", "class.name": "class.def" },
|
|
126
126
|
builtinMethods: PY_BUILTIN_METHODS,
|
|
127
127
|
};
|
|
128
|
-
|
|
128
|
+
const GO_QUERY = `
|
|
129
|
+
(function_declaration name: (identifier) @fn.name) @fn.def
|
|
130
|
+
(method_declaration name: (field_identifier) @method.name) @method.def
|
|
131
|
+
;; Specific type_spec shapes FIRST: parse.ts keeps the first classification a
|
|
132
|
+
;; node id receives (same mechanism the Python class patterns rely on), so a
|
|
133
|
+
;; struct/interface matches its specific pattern before the generic @type.def.
|
|
134
|
+
(type_spec name: (type_identifier) @struct.name type: (struct_type)) @struct.def
|
|
135
|
+
(type_spec name: (type_identifier) @iface.name type: (interface_type)) @iface.def
|
|
136
|
+
(type_spec name: (type_identifier) @type.name) @type.def
|
|
137
|
+
;; \`type X = Y\` is a distinct type_alias node, not a type_spec.
|
|
138
|
+
(type_alias name: (type_identifier) @type.name) @type.def
|
|
139
|
+
(import_spec path: [(interpreted_string_literal) (raw_string_literal)] @import.src)
|
|
140
|
+
(call_expression function: (identifier) @call.id)
|
|
141
|
+
(call_expression function: (selector_expression field: (field_identifier) @call.member))
|
|
142
|
+
`;
|
|
143
|
+
/** In Go every package-qualified call (fmt.Println, strings.Split, t.Errorf) is a
|
|
144
|
+
* selector_expression and therefore lands in @call.member — the DOMINANT call form.
|
|
145
|
+
* This allowlist filters the highest-frequency stdlib/testing method+function names
|
|
146
|
+
* so they never create false edges to same-named repo symbols; repo-specific member
|
|
147
|
+
* calls (s.Run(), h.Handle()) pass through and resolve conservatively like TS/PY. */
|
|
148
|
+
const GO_BUILTIN_METHODS = new Set([
|
|
149
|
+
"Error", "String", "Read", "Write", "Close", "Len", "Cap", "Reset", "Bytes", "Text",
|
|
150
|
+
"Scan", "Next", "Err", "Lock", "Unlock", "RLock", "RUnlock", "Done", "Add", "Wait",
|
|
151
|
+
"Print", "Printf", "Println", "Sprintf", "Fprintf", "Errorf", "Fatal", "Fatalf", "Fatalln",
|
|
152
|
+
"Log", "Logf", "Helper", "Run", "Parallel", "Skip", "Skipf", "Cleanup",
|
|
153
|
+
"Get", "Set", "Delete", "Load", "Store", "Range", "Value", "Context", "Deadline",
|
|
154
|
+
"Marshal", "Unmarshal", "Encode", "Decode", "Parse", "Format", "Sub", "Before", "After",
|
|
155
|
+
"Join", "Split", "Contains", "Replace", "ReplaceAll", "TrimSpace", "ToLower", "ToUpper",
|
|
156
|
+
"HasPrefix", "HasSuffix", "WriteString", "ReadString", "ReadAll", "Copy", "New", "Now",
|
|
157
|
+
"Since", "Sleep", "Unix", "Exec", "Query", "QueryRow", "Begin", "Commit", "Rollback",
|
|
158
|
+
]);
|
|
159
|
+
const GO = {
|
|
160
|
+
id: "go",
|
|
161
|
+
extensions: [".go"],
|
|
162
|
+
grammarKey: "go",
|
|
163
|
+
loadGrammar: () => loadNativeTreeSitter().go,
|
|
164
|
+
query: GO_QUERY,
|
|
165
|
+
defNodeTypes: new Set(["function_declaration", "method_declaration", "type_spec", "type_alias"]),
|
|
166
|
+
defKindOf: {
|
|
167
|
+
"fn.def": "function", "method.def": "method", "struct.def": "class",
|
|
168
|
+
"iface.def": "interface", "type.def": "type",
|
|
169
|
+
},
|
|
170
|
+
nameToDef: {
|
|
171
|
+
"fn.name": "fn.def", "method.name": "method.def", "struct.name": "struct.def",
|
|
172
|
+
"iface.name": "iface.def", "type.name": "type.def",
|
|
173
|
+
},
|
|
174
|
+
builtinMethods: GO_BUILTIN_METHODS,
|
|
175
|
+
};
|
|
176
|
+
export const LANGUAGES = [TYPESCRIPT, TSX, PYTHON, GO];
|
|
129
177
|
export const CODE_EXTENSIONS = [...new Set(LANGUAGES.flatMap((l) => l.extensions))];
|
|
130
178
|
export function languageFor(file) {
|
|
131
179
|
for (const lang of LANGUAGES) {
|
|
@@ -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", "tree-sitter-python"];
|
|
7
|
+
const NATIVE_PACKAGES = ["tree-sitter", "tree-sitter-typescript", "tree-sitter-python", "tree-sitter-go"];
|
|
8
8
|
let runtime = null;
|
|
9
9
|
function processIsAlive(pid) {
|
|
10
10
|
if (pid === process.pid)
|
|
@@ -69,7 +69,7 @@ export function loadNativeTreeSitter() {
|
|
|
69
69
|
// underscores (tree_sitter_runtime_binding.node, tree_sitter_python_binding.node,
|
|
70
70
|
// …). Missing the underscore names let an already-loaded source-built addon slip
|
|
71
71
|
// past this guard and defeat the file-lock isolation entirely (issue #52).
|
|
72
|
-
const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
|
|
72
|
+
const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python|-go)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
|
|
73
73
|
&& !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
|
|
74
74
|
if (preloaded.length) {
|
|
75
75
|
throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
|
|
@@ -87,7 +87,8 @@ export function loadNativeTreeSitter() {
|
|
|
87
87
|
const Parser = runtimeRequire("tree-sitter");
|
|
88
88
|
const languages = runtimeRequire("tree-sitter-typescript");
|
|
89
89
|
const python = runtimeRequire("tree-sitter-python");
|
|
90
|
-
|
|
90
|
+
const go = runtimeRequire("tree-sitter-go");
|
|
91
|
+
runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python, go };
|
|
91
92
|
}
|
|
92
93
|
catch (error) {
|
|
93
94
|
try {
|
|
@@ -24,6 +24,7 @@ export function stripManagedSection(text) {
|
|
|
24
24
|
export function renderHunchSection(store, root) {
|
|
25
25
|
const constraints = store.json
|
|
26
26
|
.loadAll("constraints")
|
|
27
|
+
.filter((c) => c.status === "active" && !c.valid_to)
|
|
27
28
|
.sort((a, b) => sev(b.severity) - sev(a.severity))
|
|
28
29
|
.slice(0, 8);
|
|
29
30
|
const counts = {
|
package/dist/mcp/server.js
CHANGED
|
@@ -18,7 +18,7 @@ import { decisionId, findingId } from "../core/ids.js";
|
|
|
18
18
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
19
19
|
import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
20
20
|
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
21
|
-
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl } from "../extractors/git.js";
|
|
21
|
+
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
|
|
22
22
|
import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
|
|
23
23
|
import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
|
|
24
24
|
import { formatStructure } from "../core/format.js";
|
|
@@ -43,6 +43,23 @@ import { existsSync } from "node:fs";
|
|
|
43
43
|
import { join } from "node:path";
|
|
44
44
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
45
45
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
46
|
+
/** Shared by every auto-committing write tool (issue #20): the MCP `roots` protocol
|
|
47
|
+
* cannot see an agent-driven `cd`/EnterWorktree, so a stdio server's cached root
|
|
48
|
+
* never moves on its own — this is the client-agnostic fallback, resolved fresh on
|
|
49
|
+
* every call by the generic tool wrapper below (see extractCwdHint). */
|
|
50
|
+
const cwdHintField = z.string().optional().describe("Your ACTUAL current working directory for THIS call. Pass it whenever it differs from where this MCP " +
|
|
51
|
+
"session started — most commonly after entering a git worktree (EnterWorktree) or `cd`-ing to a different " +
|
|
52
|
+
"checkout — so the write commits to that repo/branch instead of silently landing on the server's original " +
|
|
53
|
+
"root. Omit only when you are still in the session's starting directory.");
|
|
54
|
+
/** Pull `cwd` out of a tool call's already-parsed input without assuming any one
|
|
55
|
+
* tool's exact input shape — every write tool spreads the same cwdHintField in,
|
|
56
|
+
* but the wrapper below runs for every tool, read or write. */
|
|
57
|
+
function extractCwdHint(input) {
|
|
58
|
+
if (!input || typeof input !== "object")
|
|
59
|
+
return undefined;
|
|
60
|
+
const cwd = input.cwd;
|
|
61
|
+
return typeof cwd === "string" && cwd.trim() ? cwd : undefined;
|
|
62
|
+
}
|
|
46
63
|
/** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
|
|
47
64
|
* commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
|
|
48
65
|
* next flush sweeps it up; claiming "auto-committed" there would be a lie. */
|
|
@@ -73,6 +90,21 @@ const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
|
|
|
73
90
|
return risk;
|
|
74
91
|
return "\nℹ Landed in the COMMITTED PUBLIC store (publishes with the repo). For sensitive/strategy content, re-record with private:true — the overlay store." + risk;
|
|
75
92
|
};
|
|
93
|
+
/** Self-diagnosing destination report (issue #17/#20): the exact failure mode this
|
|
94
|
+
* guards against is silent — a capture landing in the wrong repo/branch with no
|
|
95
|
+
* sign of it short of a manual `git log` audit. Every auto-committing write tool
|
|
96
|
+
* appends this so the destination is always visible in the response, whether or
|
|
97
|
+
* not a cwd hint was involved in choosing it. */
|
|
98
|
+
const destinationNote = (destRoot) => {
|
|
99
|
+
const branch = currentBranch(destRoot);
|
|
100
|
+
return ` [captured${branch ? ` on branch ${branch}` : ""} in ${destRoot}]`;
|
|
101
|
+
};
|
|
102
|
+
/** Where a capture keyed to `home` actually lands: the private overlay directory when
|
|
103
|
+
* one is configured, else the public repo root. Centralizes the branch used at every
|
|
104
|
+
* destination-reporting call site below — `hunch_policy_upgrade_correction` once
|
|
105
|
+
* diverged from this (computing its own `artifactHome` but reporting the public root
|
|
106
|
+
* regardless), silently misreporting the destination for a private-homed proof. */
|
|
107
|
+
const resolveDestRoot = (home, store, root) => home === "private" && store.privateDir ? store.privateDir : root;
|
|
76
108
|
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
77
109
|
// session, so an uncapped list pollutes the context window. Cap each list to its
|
|
78
110
|
// highest-signal head (records are pre-sorted by severity/confidence) and tell the
|
|
@@ -435,6 +467,30 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
435
467
|
};
|
|
436
468
|
const registerTool = server.registerTool.bind(server);
|
|
437
469
|
server.registerTool = ((name, config, callback) => registerTool(name, config, async (...args) => {
|
|
470
|
+
// Claude Code CLI never advertises `roots`/`roots/list_changed` for an agent-driven
|
|
471
|
+
// `cd` or EnterWorktree (issue #20) — the cached `root` above just never moves, so a
|
|
472
|
+
// write silently lands wherever the process was spawned. Write tools accept an
|
|
473
|
+
// optional `cwd` argument (see cwdHintField) as a client-agnostic fallback: resolved
|
|
474
|
+
// fresh on EVERY call instead of trusted from a cache, and re-homing this stdio
|
|
475
|
+
// process the same way a `roots` notification would. Only safe when this is the
|
|
476
|
+
// sole in-flight request — re-homing under a concurrent request would tear its
|
|
477
|
+
// root/store out from under it, so that case is refused rather than risked.
|
|
478
|
+
const cwdHint = extractCwdHint(args[0]);
|
|
479
|
+
if (cwdHint !== undefined) {
|
|
480
|
+
const target = canonicalRootPath(findRoot(cwdHint));
|
|
481
|
+
if (target !== canonicalRootPath(root)) {
|
|
482
|
+
if (activeRequests) {
|
|
483
|
+
return err(`Hunch is mid-request against ${root} and cannot safely switch to the working directory you passed ` +
|
|
484
|
+
`(resolves to ${target}) while another call is in flight. Retry this call once the other one completes.`);
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
setRoot(cwdHint);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
return err(`Failed to switch Hunch to your working directory (${cwdHint}): ${error.message}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
438
494
|
activeRequests++;
|
|
439
495
|
try {
|
|
440
496
|
// Routing is live state, not a startup constant. A branch switch or
|
|
@@ -857,6 +913,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
857
913
|
private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — for sensitive decisions kept out of a public repo. Errors if no private store is configured."),
|
|
858
914
|
}),
|
|
859
915
|
capture_token: z.string().optional().describe("token from hunch_capture_decision — proves this write is the tail of a grilling interview. Omit only for a quick manual record (a deprecation nudge is returned)."),
|
|
916
|
+
cwd: cwdHintField,
|
|
860
917
|
},
|
|
861
918
|
}, async ({ decision, capture_token }) => {
|
|
862
919
|
try {
|
|
@@ -1029,7 +1086,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1029
1086
|
const where = decision.private
|
|
1030
1087
|
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
1031
1088
|
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
1032
|
-
|
|
1089
|
+
const dest = destinationNote(resolveDestRoot(home, store, root));
|
|
1090
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${dest}${supNote}${note}${captureNote}${quality}`);
|
|
1033
1091
|
}
|
|
1034
1092
|
catch (e) {
|
|
1035
1093
|
return err(`Failed to record decision: ${e.message}`);
|
|
@@ -1049,6 +1107,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1049
1107
|
source_decision: z.string().optional().describe("id of a decision this correction derives from."),
|
|
1050
1108
|
private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — a sensitive rule enforced locally (pre-edit hook + local check) but never exposed in a public PR comment. Errors if no private store is configured."),
|
|
1051
1109
|
capture_token: z.string().optional().describe("token from hunch_capture_decision. The rule is recorded and enforced either way — the token only decides whether it may DENY: without one it lands as advisory testimony capped at severity 'warning'."),
|
|
1110
|
+
cwd: cwdHintField,
|
|
1052
1111
|
},
|
|
1053
1112
|
}, async (input) => {
|
|
1054
1113
|
try {
|
|
@@ -1103,7 +1162,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1103
1162
|
: `
|
|
1104
1163
|
|
|
1105
1164
|
⚠ Recorded WITHOUT a capture interview — this rule is agent_recorded TESTIMONY${input.severity === "blocking" ? ' and was capped from "blocking" to "warning"' : ""}. It IS enforced: the pre-edit hook and CI surface it on every matching edit from now on. What it cannot do is DENY an edit — only a rule a human countersigned may block. Countersign it by re-recording through hunch_capture_decision → hunch_record_correction(capture_token).`;
|
|
1106
|
-
|
|
1165
|
+
const dest = destinationNote(resolveDestRoot(home, store, root));
|
|
1166
|
+
return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where}${dest} It now ${enforce}.${reviewNote}${tierNote}`);
|
|
1107
1167
|
}
|
|
1108
1168
|
catch (e) {
|
|
1109
1169
|
return err(`Failed to record correction: ${e.message}`);
|
|
@@ -1128,6 +1188,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1128
1188
|
resolved_commit: z.string().optional().describe("the commit that fixed it (with triage:'resolved')"),
|
|
1129
1189
|
private: z.boolean().optional().describe("write into the PRIVATE overlay store instead of the committed repo. Errors if no private store is configured."),
|
|
1130
1190
|
}),
|
|
1191
|
+
cwd: cwdHintField,
|
|
1131
1192
|
},
|
|
1132
1193
|
}, async ({ finding }) => {
|
|
1133
1194
|
try {
|
|
@@ -1172,7 +1233,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1172
1233
|
? `\n\n△ violates_constraint ${rec.violates_constraint} resolves to no known constraint — if the rule isn't recorded yet, hunch_record_correction it and re-record this finding with the real id.`
|
|
1173
1234
|
: "";
|
|
1174
1235
|
const noEvidence = rec.evidence.length ? "" : "\n\n△ No evidence attached — a finding without the query/output that produced it is an opinion. Re-record with evidence when you have it.";
|
|
1175
|
-
|
|
1236
|
+
const dest = destinationNote(resolveDestRoot(home, store, root));
|
|
1237
|
+
return ok(`${existing ? "Updated" : "Recorded"} finding ${id}: "${rec.title}" (${rec.triage}/${rec.severity}, observed ${rec.observed_at.slice(0, 10)}).${where}${dest} It now grounds edits to: ${[...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)"}.${danglingCon}${noEvidence}`);
|
|
1176
1238
|
}
|
|
1177
1239
|
catch (e) {
|
|
1178
1240
|
return err(`Failed to record finding: ${e.message}`);
|
|
@@ -1207,6 +1269,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1207
1269
|
public_only: z.boolean().optional().describe("Read and write only the public correction home."),
|
|
1208
1270
|
private_only: z.boolean().optional().describe("Keep correction-derived evidence/policy/proof artifacts in the configured private overlay; the public source-code graph is refreshed before proof."),
|
|
1209
1271
|
include_artifacts: z.boolean().optional().describe("Include the complete Policy IR, proof plan, proof receipts, and evidence object. Default output is a concise review envelope."),
|
|
1272
|
+
cwd: cwdHintField,
|
|
1210
1273
|
},
|
|
1211
1274
|
}, async ({ constraint_id, public_only, private_only, include_artifacts }) => {
|
|
1212
1275
|
try {
|
|
@@ -1244,8 +1307,10 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1244
1307
|
if (artifactHome === "private") {
|
|
1245
1308
|
flushMemoryHome(store, hunchPaths(root).hunch, "private", `hunch: prove correction ${constraint_id}`, startupTeamRoute ?? undefined);
|
|
1246
1309
|
}
|
|
1310
|
+
const destRoot = resolveDestRoot(artifactHome, store, root);
|
|
1311
|
+
const destination = { root: destRoot, branch: currentBranch(destRoot) };
|
|
1247
1312
|
if (include_artifacts)
|
|
1248
|
-
return ok(JSON.stringify(upgrade, null, 2));
|
|
1313
|
+
return ok(JSON.stringify({ ...upgrade, destination }, null, 2));
|
|
1249
1314
|
return ok(JSON.stringify({
|
|
1250
1315
|
status: upgrade.status,
|
|
1251
1316
|
correction_id: upgrade.correction_id,
|
|
@@ -1258,6 +1323,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1258
1323
|
authority: upgrade.authority,
|
|
1259
1324
|
effects: upgrade.effects,
|
|
1260
1325
|
activation: upgrade.activation,
|
|
1326
|
+
destination,
|
|
1261
1327
|
}, null, 2));
|
|
1262
1328
|
}
|
|
1263
1329
|
catch (e) {
|
|
@@ -1416,6 +1482,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1416
1482
|
inputSchema: {
|
|
1417
1483
|
policy_id: z.string().describe("Policy id (pol_*)."),
|
|
1418
1484
|
public_only: z.boolean().optional().describe("Exclude private-overlay policy and evidence records."),
|
|
1485
|
+
cwd: cwdHintField,
|
|
1419
1486
|
},
|
|
1420
1487
|
}, async ({ policy_id, public_only }) => {
|
|
1421
1488
|
try {
|
|
@@ -1425,7 +1492,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1425
1492
|
if (!home)
|
|
1426
1493
|
throw new Error(`policy ${policy_id} has no exact storage home`);
|
|
1427
1494
|
flushMemoryHome(store, hunchPaths(root).hunch, home, `hunch: plan policy ${policy_id}`, startupTeamRoute ?? undefined);
|
|
1428
|
-
|
|
1495
|
+
const destRoot = resolveDestRoot(home, store, root);
|
|
1496
|
+
return ok(JSON.stringify({ ...plan, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
|
|
1429
1497
|
}
|
|
1430
1498
|
catch (e) {
|
|
1431
1499
|
return err(`Failed to generate policy proof plan: ${e.message}`);
|
|
@@ -1692,6 +1760,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1692
1760
|
limit: z.number().int().min(1).max(100).optional().describe("Item limit used by the exact review packet (default 30)."),
|
|
1693
1761
|
allow_install_scripts: z.array(z.string().min(1).max(214)).max(20).optional().describe("Exact dependency package names allowed to run lifecycle scripts while provisioning snapshots."),
|
|
1694
1762
|
dependency_timeout_ms: z.number().int().min(1).max(900000).optional().describe("Timeout for each exact dependency snapshot operation (default 300000ms)."),
|
|
1763
|
+
cwd: cwdHintField,
|
|
1695
1764
|
},
|
|
1696
1765
|
}, async ({ decision_id, since, max_commits, limit, allow_install_scripts, dependency_timeout_ms }) => {
|
|
1697
1766
|
try {
|
|
@@ -1704,7 +1773,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1704
1773
|
dependencyTimeoutMs: dependency_timeout_ms ?? 300_000,
|
|
1705
1774
|
});
|
|
1706
1775
|
flushMemoryHome(store, hunchPaths(root).hunch, "private", "hunch: materialize G2 behavior policies", startupTeamRoute ?? undefined);
|
|
1707
|
-
|
|
1776
|
+
const destRoot = resolveDestRoot("private", store, root);
|
|
1777
|
+
return ok(JSON.stringify({ ...materialized, destination: { root: destRoot, branch: currentBranch(destRoot) } }, null, 2));
|
|
1708
1778
|
}
|
|
1709
1779
|
catch (e) {
|
|
1710
1780
|
return err(`Failed to materialize G2 behavior policies: ${e.message}`);
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -662,7 +662,11 @@ export class HunchStore {
|
|
|
662
662
|
// The graph stream is model-free, so it contributes even on a lean (no-embeddings)
|
|
663
663
|
// install. With neither semantic nor graph signal, return pure FTS so the
|
|
664
664
|
// zero-fusion-overhead fast path is preserved.
|
|
665
|
-
const graph = this.graphExpand([...fts, ...sem],
|
|
665
|
+
const graph = this.graphExpand([...fts, ...sem], {
|
|
666
|
+
maxDepth: boundedWhole(opts.graphDepth, GRAPH_MAX_DEPTH, GRAPH_DEPTH_HARD_MAX),
|
|
667
|
+
nodeCap: boundedWhole(opts.graphNodeCap, GRAPH_NODE_CAP, GRAPH_NODE_HARD_MAX),
|
|
668
|
+
tokenCap: boundedWhole(opts.graphTokenCap, GRAPH_TOKEN_CAP, GRAPH_TOKEN_HARD_MAX),
|
|
669
|
+
}, gw);
|
|
666
670
|
if (!sem.length && !graph.length)
|
|
667
671
|
return this.rerankByPriors(fts, limit, query);
|
|
668
672
|
// Fuse with headroom so the prior rerank can promote from below the cut line.
|
|
@@ -764,47 +768,91 @@ export class HunchStore {
|
|
|
764
768
|
add(graph, graphWeight);
|
|
765
769
|
return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, limit).map((e) => ({ ...e.hit, score: e.score }));
|
|
766
770
|
}
|
|
767
|
-
/**
|
|
768
|
-
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
if (weight <= 0)
|
|
771
|
+
/** Bounded relevance traversal over the dependency graph. Lexical/semantic symbol
|
|
772
|
+
* and component hits seed a small number of depth layers; support decays per hop
|
|
773
|
+
* and adds across multiple useful paths. Each frontier and the returned context
|
|
774
|
+
* obey a hard node cap, while hydration obeys a separate token cap. Only records
|
|
775
|
+
* present in the indexed symbol/component tables can enter the frontier, so
|
|
776
|
+
* shared external-package hubs never become context or bridge unrelated symbols. */
|
|
777
|
+
graphExpand(seeds, opts, weight = RRF_W_GRAPH) {
|
|
778
|
+
if (weight <= 0 || GRAPH_GAMMA <= 0 || opts.maxDepth <= 0 || opts.nodeCap <= 0 || opts.tokenCap <= 0)
|
|
776
779
|
return [];
|
|
777
|
-
const
|
|
778
|
-
|
|
780
|
+
const seedRefs = new Set(seeds.map((h) => h.ref)); // never re-surface a seed
|
|
781
|
+
let frontier = new Map();
|
|
782
|
+
seeds.forEach((hit, rank) => {
|
|
783
|
+
if (!isGraphContextRef(hit.ref))
|
|
784
|
+
return;
|
|
785
|
+
frontier.set(hit.ref, (frontier.get(hit.ref) ?? 0) + 1 / (RRF_K + rank + 1));
|
|
786
|
+
});
|
|
787
|
+
if (!frontier.size)
|
|
779
788
|
return [];
|
|
780
|
-
const seen = new Set(seeds.map((h) => h.ref)); // never re-surface a seed
|
|
781
789
|
const nbStmt = this.db.prepare(
|
|
782
790
|
/* sql */ `
|
|
783
|
-
SELECT e."to"
|
|
791
|
+
SELECT e."to" AS nb
|
|
792
|
+
FROM edges e
|
|
793
|
+
WHERE e."from" = ? AND e.type IN ('calls','depends_on','imports','contains')
|
|
794
|
+
AND (EXISTS (SELECT 1 FROM symbols s WHERE s.id = e."to")
|
|
795
|
+
OR EXISTS (SELECT 1 FROM components c WHERE c.id = e."to"))
|
|
784
796
|
UNION
|
|
785
|
-
SELECT e."from" AS nb
|
|
797
|
+
SELECT e."from" AS nb
|
|
798
|
+
FROM edges e
|
|
799
|
+
WHERE e."to" = ? AND e.type IN ('calls','depends_on','imports','contains')
|
|
800
|
+
AND (EXISTS (SELECT 1 FROM symbols s WHERE s.id = e."from")
|
|
801
|
+
OR EXISTS (SELECT 1 FROM components c WHERE c.id = e."from"))
|
|
802
|
+
ORDER BY nb`);
|
|
803
|
+
const expanded = new Set();
|
|
786
804
|
const score = new Map();
|
|
787
|
-
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
805
|
+
for (let depth = 1; depth <= opts.maxDepth && frontier.size; depth++) {
|
|
806
|
+
const layer = new Map();
|
|
807
|
+
const rankedFrontier = [...frontier.entries()]
|
|
808
|
+
.sort((a, b) => b[1] - a[1] || compareRefs(a[0], b[0]))
|
|
809
|
+
.slice(0, opts.nodeCap)
|
|
810
|
+
.filter(([ref]) => !expanded.has(ref));
|
|
811
|
+
// Mark the whole frontier visited before walking it. Otherwise an edge
|
|
812
|
+
// between peers in this layer credits whichever peer sorts second as if
|
|
813
|
+
// it were deeper context, making scores depend on ref ordering.
|
|
814
|
+
for (const [ref] of rankedFrontier)
|
|
815
|
+
expanded.add(ref);
|
|
816
|
+
for (const [ref, support] of rankedFrontier) {
|
|
817
|
+
const contribution = support * GRAPH_GAMMA;
|
|
818
|
+
for (const row of nbStmt.all(ref, ref)) {
|
|
819
|
+
if (seedRefs.has(row.nb) || expanded.has(row.nb))
|
|
820
|
+
continue;
|
|
821
|
+
layer.set(row.nb, (layer.get(row.nb) ?? 0) + contribution);
|
|
822
|
+
}
|
|
793
823
|
}
|
|
794
|
-
|
|
824
|
+
const rankedLayer = [...layer.entries()]
|
|
825
|
+
.sort((a, b) => b[1] - a[1] || compareRefs(a[0], b[0]))
|
|
826
|
+
.slice(0, opts.nodeCap);
|
|
827
|
+
for (const [ref, support] of rankedLayer)
|
|
828
|
+
score.set(ref, (score.get(ref) ?? 0) + support);
|
|
829
|
+
frontier = new Map(rankedLayer);
|
|
830
|
+
}
|
|
795
831
|
if (!score.size)
|
|
796
832
|
return [];
|
|
797
|
-
const top = [...score.entries()]
|
|
833
|
+
const top = [...score.entries()]
|
|
834
|
+
.sort((a, b) => b[1] - a[1] || compareRefs(a[0], b[0]))
|
|
835
|
+
.slice(0, opts.nodeCap);
|
|
798
836
|
// Hydrate title/snippet from the FTS table in ONE query (mirrors cosineRank).
|
|
799
837
|
const placeholders = top.map(() => "?").join(",");
|
|
800
838
|
const meta = new Map();
|
|
801
|
-
for (const row of this.db.prepare(`SELECT ref, title, body FROM search WHERE ref IN (${placeholders})`).all(...top.map(([ref]) => ref))) {
|
|
802
|
-
meta.set(row.ref, { title: row.title, body: row.body });
|
|
839
|
+
for (const row of this.db.prepare(`SELECT ref, kind, title, body FROM search WHERE ref IN (${placeholders})`).all(...top.map(([ref]) => ref))) {
|
|
840
|
+
meta.set(row.ref, { kind: row.kind, title: row.title, body: row.body });
|
|
803
841
|
}
|
|
804
|
-
|
|
842
|
+
const hits = [];
|
|
843
|
+
let usedTokens = 0;
|
|
844
|
+
for (const [ref, s] of top) {
|
|
805
845
|
const m = meta.get(ref);
|
|
806
|
-
|
|
807
|
-
|
|
846
|
+
if (!m)
|
|
847
|
+
continue;
|
|
848
|
+
const hit = { ref, kind: m.kind, title: m.title, snippet: m.body.slice(0, 120), score: s };
|
|
849
|
+
const tokenCost = estimatedSearchHitTokens(hit);
|
|
850
|
+
if (usedTokens + tokenCost > opts.tokenCap)
|
|
851
|
+
continue;
|
|
852
|
+
hits.push(hit);
|
|
853
|
+
usedTokens += tokenCost;
|
|
854
|
+
}
|
|
855
|
+
return hits;
|
|
808
856
|
}
|
|
809
857
|
/** All decisions/bugs/constraints/symbols/components touching a file path or
|
|
810
858
|
* symbol name (hunch_why). Pass `{ asOf }` (an ISO instant) to TIME-TRAVEL:
|
|
@@ -1581,7 +1629,13 @@ const RRF_K = numEnv("HUNCH_RRF_K", 60);
|
|
|
1581
1629
|
const RRF_W_FTS = numEnv("HUNCH_RRF_W_FTS", 1);
|
|
1582
1630
|
const RRF_W_SEM = numEnv("HUNCH_RRF_W_SEM", 0.7);
|
|
1583
1631
|
const RRF_W_GRAPH = numEnv("HUNCH_RRF_W_GRAPH", 0.5);
|
|
1584
|
-
const GRAPH_GAMMA = numEnv("HUNCH_GRAPH_GAMMA", 0.25);
|
|
1632
|
+
const GRAPH_GAMMA = Math.min(1, numEnv("HUNCH_GRAPH_GAMMA", 0.25));
|
|
1633
|
+
const GRAPH_DEPTH_HARD_MAX = 8;
|
|
1634
|
+
const GRAPH_NODE_HARD_MAX = 500;
|
|
1635
|
+
const GRAPH_TOKEN_HARD_MAX = 100_000;
|
|
1636
|
+
const GRAPH_MAX_DEPTH = boundedWhole(numEnv("HUNCH_GRAPH_MAX_DEPTH", 2), 2, GRAPH_DEPTH_HARD_MAX);
|
|
1637
|
+
const GRAPH_NODE_CAP = boundedWhole(numEnv("HUNCH_GRAPH_NODE_CAP", 50), 50, GRAPH_NODE_HARD_MAX);
|
|
1638
|
+
const GRAPH_TOKEN_CAP = boundedWhole(numEnv("HUNCH_GRAPH_TOKEN_CAP", 2_000), 2_000, GRAPH_TOKEN_HARD_MAX);
|
|
1585
1639
|
/** Prior tuning: how far a trust weight may move a hit from its FUSED position.
|
|
1586
1640
|
* SCALE maps the weight's realistic span onto positions (this repo's own decisions
|
|
1587
1641
|
* span w 0.48…1.0, so ×12 reaches the clamp at the low end); MAX_PRIOR_SHIFT is the
|
|
@@ -1595,6 +1649,20 @@ function numEnv(name, dflt) {
|
|
|
1595
1649
|
// a stream); rejecting it silently re-enabled the default weight (issue #33).
|
|
1596
1650
|
return Number.isFinite(v) && v >= 0 ? v : dflt;
|
|
1597
1651
|
}
|
|
1652
|
+
function boundedWhole(value, dflt, hardMax) {
|
|
1653
|
+
if (value === undefined || !Number.isFinite(value) || value < 0)
|
|
1654
|
+
return dflt;
|
|
1655
|
+
return Math.min(Math.floor(value), hardMax);
|
|
1656
|
+
}
|
|
1657
|
+
function isGraphContextRef(ref) {
|
|
1658
|
+
return ref.startsWith("sym_") || ref.startsWith("cmp_");
|
|
1659
|
+
}
|
|
1660
|
+
function compareRefs(a, b) {
|
|
1661
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1662
|
+
}
|
|
1663
|
+
function estimatedSearchHitTokens(hit) {
|
|
1664
|
+
return Math.max(1, Math.ceil([...`${hit.kind} ${hit.ref}\n${hit.title}\n${hit.snippet}`].length / 4));
|
|
1665
|
+
}
|
|
1598
1666
|
/** Pack a vector's exact bytes for SQLite. Explicit offset+length so a SUBARRAY
|
|
1599
1667
|
* view (byteOffset != 0) writes only its slice, not the whole backing buffer.
|
|
1600
1668
|
* node:sqlite copies on bind, so the returned view never aliases the row. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
75
75
|
"commander": "^15.0.0",
|
|
76
76
|
"tree-sitter": "0.21.1",
|
|
77
|
+
"tree-sitter-go": "^0.23.4",
|
|
77
78
|
"tree-sitter-python": "^0.23.2",
|
|
78
79
|
"tree-sitter-typescript": "^0.23.2",
|
|
79
80
|
"zod": "^4.4.3"
|
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.
|
|
10
|
+
"version": "1.15.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
|
+
"version": "1.15.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|