@davesheffer/hunch 0.1.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/LICENSE +21 -0
- package/README.md +241 -0
- package/dist/cli/index.js +587 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/core/format.js +46 -0
- package/dist/core/glob.js +62 -0
- package/dist/core/ids.js +39 -0
- package/dist/core/io.js +44 -0
- package/dist/core/migrate.js +59 -0
- package/dist/core/paths.js +41 -0
- package/dist/core/types.js +142 -0
- package/dist/extractors/diff.js +198 -0
- package/dist/extractors/git.js +136 -0
- package/dist/extractors/indexer.js +271 -0
- package/dist/extractors/parse.js +176 -0
- package/dist/integrations/claudemd.js +77 -0
- package/dist/integrations/hooks.js +80 -0
- package/dist/integrations/mergeDriver.js +41 -0
- package/dist/integrations/scaffold.js +74 -0
- package/dist/mcp/server.js +233 -0
- package/dist/store/compact.js +100 -0
- package/dist/store/db.js +19 -0
- package/dist/store/embedder.js +133 -0
- package/dist/store/hunchStore.js +469 -0
- package/dist/store/jsonStore.js +268 -0
- package/dist/store/merge.js +179 -0
- package/dist/store/schema.js +100 -0
- package/dist/synthesis/provider.js +488 -0
- package/dist/synthesis/synthesize.js +312 -0
- package/package.json +68 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git post-commit hook installer (DESIGN.md §4 / §6). The hook fires the
|
|
3
|
+
* learning loop after every commit. Loop-guarded via the HUNCH_SYNC env var, and
|
|
4
|
+
* backgrounded so it never slows a commit down. Existing hooks are preserved —
|
|
5
|
+
* we append a guarded block rather than clobbering.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { hooksDir } from "../extractors/git.js";
|
|
10
|
+
const MARK = "# >>> hunch post-commit >>>";
|
|
11
|
+
const ENDMARK = "# <<< hunch post-commit <<<";
|
|
12
|
+
function block(invocation) {
|
|
13
|
+
return [
|
|
14
|
+
MARK,
|
|
15
|
+
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
16
|
+
" export HUNCH_SYNC=1",
|
|
17
|
+
` ( ${invocation} sync --from-hook --quiet >/dev/null 2>&1 || true ) &`,
|
|
18
|
+
"fi",
|
|
19
|
+
ENDMARK,
|
|
20
|
+
].join("\n");
|
|
21
|
+
}
|
|
22
|
+
export function installPostCommitHook(root, invocation) {
|
|
23
|
+
const dir = hooksDir(root);
|
|
24
|
+
const abs = dir.startsWith("/") ? dir : join(root, dir);
|
|
25
|
+
mkdirSync(abs, { recursive: true });
|
|
26
|
+
const hookPath = join(abs, "post-commit");
|
|
27
|
+
const blk = block(invocation);
|
|
28
|
+
if (!existsSync(hookPath)) {
|
|
29
|
+
writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
|
|
30
|
+
chmodSync(hookPath, 0o755);
|
|
31
|
+
return { path: hookPath, action: "created" };
|
|
32
|
+
}
|
|
33
|
+
const cur = readFileSync(hookPath, "utf8");
|
|
34
|
+
if (cur.includes(MARK)) {
|
|
35
|
+
// replace our managed block (invocation may have changed)
|
|
36
|
+
const updated = cur.replace(new RegExp(`${escapeRe(MARK)}[\\s\\S]*?${escapeRe(ENDMARK)}`), blk);
|
|
37
|
+
if (updated === cur)
|
|
38
|
+
return { path: hookPath, action: "unchanged" };
|
|
39
|
+
writeFileSync(hookPath, updated);
|
|
40
|
+
chmodSync(hookPath, 0o755);
|
|
41
|
+
return { path: hookPath, action: "updated" };
|
|
42
|
+
}
|
|
43
|
+
const appended = cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`;
|
|
44
|
+
writeFileSync(hookPath, appended);
|
|
45
|
+
chmodSync(hookPath, 0o755);
|
|
46
|
+
return { path: hookPath, action: "appended" };
|
|
47
|
+
}
|
|
48
|
+
function escapeRe(s) {
|
|
49
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
50
|
+
}
|
|
51
|
+
const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
|
|
52
|
+
const PRE_END = "# <<< hunch pre-commit <<<";
|
|
53
|
+
/** Install a pre-commit constraint guard (DESIGN §4 enforcement). Advisory by
|
|
54
|
+
* default (prints invariants in scope, never blocks); pass strict to fail the
|
|
55
|
+
* commit on a blocking invariant. Preserves any existing pre-commit hook. */
|
|
56
|
+
export function installPreCommitHook(root, invocation, strict = false) {
|
|
57
|
+
const dir = hooksDir(root);
|
|
58
|
+
const abs = dir.startsWith("/") ? dir : join(root, dir);
|
|
59
|
+
mkdirSync(abs, { recursive: true });
|
|
60
|
+
const hookPath = join(abs, "pre-commit");
|
|
61
|
+
const cmd = `${invocation} check --staged${strict ? " --strict" : ""}`;
|
|
62
|
+
const blk = [PRE_MARK, strict ? cmd : `${cmd} || true`, PRE_END].join("\n");
|
|
63
|
+
if (!existsSync(hookPath)) {
|
|
64
|
+
writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
|
|
65
|
+
chmodSync(hookPath, 0o755);
|
|
66
|
+
return { path: hookPath, action: "created" };
|
|
67
|
+
}
|
|
68
|
+
const cur = readFileSync(hookPath, "utf8");
|
|
69
|
+
if (cur.includes(PRE_MARK)) {
|
|
70
|
+
const updated = cur.replace(new RegExp(`${escapeRe(PRE_MARK)}[\\s\\S]*?${escapeRe(PRE_END)}`), blk);
|
|
71
|
+
if (updated === cur)
|
|
72
|
+
return { path: hookPath, action: "unchanged" };
|
|
73
|
+
writeFileSync(hookPath, updated);
|
|
74
|
+
return { path: hookPath, action: "updated" };
|
|
75
|
+
}
|
|
76
|
+
writeFileSync(hookPath, cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`);
|
|
77
|
+
chmodSync(hookPath, 0o755);
|
|
78
|
+
return { path: hookPath, action: "appended" };
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=hooks.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires up the structured `.hunch/` git merge driver (store/merge.ts):
|
|
3
|
+
* - `.gitattributes` (committed) routes the .hunch JSON files through merge=hunch,
|
|
4
|
+
* - local git config maps merge=hunch to `hunch merge-driver …` (per clone, so
|
|
5
|
+
* each teammate runs `hunch init` to register it).
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
11
|
+
// Route the .hunch JSON records through the structured driver — but NOT the
|
|
12
|
+
// manifest (an id-less `{schema_version}` object the driver can't merge by id; a
|
|
13
|
+
// normal text merge with conflict markers is the right behavior for it).
|
|
14
|
+
const ATTR_LINES = [".hunch/**/*.json merge=hunch", ".hunch/manifest.json merge=text"];
|
|
15
|
+
export function installMergeDriver(root, invShell) {
|
|
16
|
+
// 1. .gitattributes — committed, shared with the team so the routing travels.
|
|
17
|
+
const attrPath = join(root, ".gitattributes");
|
|
18
|
+
let text = existsSync(attrPath) ? readFileSync(attrPath, "utf8") : "";
|
|
19
|
+
let attrAction = "present";
|
|
20
|
+
for (const line of ATTR_LINES) {
|
|
21
|
+
if (!text.split(/\r?\n/).some((l) => l.trim() === line)) {
|
|
22
|
+
const sep = text && !text.endsWith("\n") ? "\n" : "";
|
|
23
|
+
text += sep + line + "\n";
|
|
24
|
+
attrAction = "written";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (attrAction === "written")
|
|
28
|
+
writeFileAtomic(attrPath, text);
|
|
29
|
+
// 2. Local git config — the driver definition is per-clone (it references this
|
|
30
|
+
// machine's node + cli path), so it is NOT committed; teammates re-run init.
|
|
31
|
+
const driver = `${invShell} merge-driver "%O" "%A" "%B" "%P"`;
|
|
32
|
+
try {
|
|
33
|
+
execFileSync("git", ["config", "merge.hunch.name", "hunch structured JSON merge"], { cwd: root });
|
|
34
|
+
execFileSync("git", ["config", "merge.hunch.driver", driver], { cwd: root });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { action: `${attrAction} .gitattributes — but \`git config\` failed (not a git repo?)` };
|
|
38
|
+
}
|
|
39
|
+
return { action: `${attrAction} .gitattributes + registered merge.hunch driver` };
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=mergeDriver.js.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes the two remaining Claude Code integration surfaces (DESIGN.md §7):
|
|
3
|
+
* - .mcp.json → registers the `hunch` MCP server with Claude Code
|
|
4
|
+
* - .claude/commands/* → user-triggered slash commands for the §5 workflows
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
/** Merge a `hunch` server entry into .mcp.json, preserving other servers. */
|
|
9
|
+
export function writeMcpJson(root, inv) {
|
|
10
|
+
const file = join(root, ".mcp.json");
|
|
11
|
+
let json = {};
|
|
12
|
+
if (existsSync(file)) {
|
|
13
|
+
try {
|
|
14
|
+
json = JSON.parse(readFileSync(file, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
json = {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
json.mcpServers = json.mcpServers ?? {};
|
|
21
|
+
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
22
|
+
writeFileSync(file, JSON.stringify(json, null, 2) + "\n");
|
|
23
|
+
return file;
|
|
24
|
+
}
|
|
25
|
+
const WHY_CMD = `---
|
|
26
|
+
description: Explain why a file or symbol is the way it is, from Hunch
|
|
27
|
+
---
|
|
28
|
+
Use the \`hunch_why\` MCP tool on **$ARGUMENTS** (a file path or symbol name).
|
|
29
|
+
|
|
30
|
+
Then summarize, with citations:
|
|
31
|
+
- the **decisions** that shaped it (id + rationale),
|
|
32
|
+
- the **constraints** that must not break,
|
|
33
|
+
- the **bug history** behind it (root causes).
|
|
34
|
+
|
|
35
|
+
Cite record ids and their provenance/confidence. If Hunch returns nothing,
|
|
36
|
+
say so plainly and suggest running \`hunch index\` or \`hunch backfill\`.
|
|
37
|
+
`;
|
|
38
|
+
const FIX_CMD = `---
|
|
39
|
+
description: Fix a bug grounded in Hunch (past root causes, constraints, blast radius)
|
|
40
|
+
---
|
|
41
|
+
We are fixing: **$ARGUMENTS**
|
|
42
|
+
|
|
43
|
+
Follow the Hunch-grounded workflow (DESIGN §5) — do NOT skip the memory lookups:
|
|
44
|
+
1. \`hunch_bug_lineage("$ARGUMENTS")\` — has this class of bug happened before? what was the root cause and the fix?
|
|
45
|
+
2. Identify the suspect symbol/file, then \`hunch_get_dependents(<symbol>)\` to learn the blast radius.
|
|
46
|
+
3. \`hunch_check_constraints(<scope>)\` — list invariants you must preserve.
|
|
47
|
+
4. Propose a fix that honors past root causes AND constraints. Apply it and run the tests.
|
|
48
|
+
5. If the fix encodes a non-trivial choice, \`hunch_record_decision(...)\` so the next session is grounded in it.
|
|
49
|
+
`;
|
|
50
|
+
const FRAGILE_CMD = `---
|
|
51
|
+
description: Report the most fragile parts of this codebase, with evidence
|
|
52
|
+
---
|
|
53
|
+
Ask Hunch for the fragility ranking (run \`hunch fragile\` or query Hunch),
|
|
54
|
+
then produce a **fragility report with evidence**: the specific files/functions,
|
|
55
|
+
the bug history behind them, their churn and fan-in, and any missing guards.
|
|
56
|
+
Avoid generic advice — every claim must cite a Hunch record or metric.
|
|
57
|
+
`;
|
|
58
|
+
export function writeSlashCommands(root) {
|
|
59
|
+
const dir = join(root, ".claude", "commands");
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
const written = [];
|
|
62
|
+
const files = [
|
|
63
|
+
["hunch-why.md", WHY_CMD],
|
|
64
|
+
["hunch-fix.md", FIX_CMD],
|
|
65
|
+
["hunch-fragile.md", FRAGILE_CMD],
|
|
66
|
+
];
|
|
67
|
+
for (const [name, body] of files) {
|
|
68
|
+
const p = join(dir, name);
|
|
69
|
+
writeFileSync(p, body);
|
|
70
|
+
written.push(p);
|
|
71
|
+
}
|
|
72
|
+
return written;
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=scaffold.js.map
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server — the structured two-way API into the Hunch (DESIGN.md §7 / App. A).
|
|
3
|
+
* Exposes read tools (query/why/bug_lineage/check_constraints/get_dependents) and
|
|
4
|
+
* a write tool (record_decision). Registered with Claude Code via .mcp.json.
|
|
5
|
+
*
|
|
6
|
+
* STDIO PROTOCOL RULE: stdout carries JSON-RPC — never console.log here. All
|
|
7
|
+
* diagnostics go to stderr.
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { hunchPaths, findRoot } from "../core/paths.js";
|
|
13
|
+
import { HunchStore } from "../store/hunchStore.js";
|
|
14
|
+
import { selectEmbedder } from "../store/embedder.js";
|
|
15
|
+
import { decisionId } from "../core/ids.js";
|
|
16
|
+
import { revParse } from "../extractors/git.js";
|
|
17
|
+
import { formatContext } from "../core/format.js";
|
|
18
|
+
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
19
|
+
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
20
|
+
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
21
|
+
// session, so an uncapped list pollutes the context window. Cap each list to its
|
|
22
|
+
// highest-signal head (records are pre-sorted by severity/confidence) and tell the
|
|
23
|
+
// caller what was withheld rather than truncating silently.
|
|
24
|
+
const WHY_CAP = 6; // per record-type in hunch_why
|
|
25
|
+
const DEP_CAP = 25; // dependents in hunch_get_dependents
|
|
26
|
+
const QUERY_HITS = 8; // hunch_query matches (was 12)
|
|
27
|
+
const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
|
|
28
|
+
const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
29
|
+
const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
|
|
30
|
+
/** Resolve a free-form target (symbol id / name / file path) to symbol records. */
|
|
31
|
+
function resolveSymbols(store, target) {
|
|
32
|
+
const syms = store.json.loadAll("symbols");
|
|
33
|
+
const byId = syms.find((s) => s.id === target);
|
|
34
|
+
if (byId)
|
|
35
|
+
return [byId];
|
|
36
|
+
const byName = syms.filter((s) => s.name === target);
|
|
37
|
+
if (byName.length)
|
|
38
|
+
return byName;
|
|
39
|
+
return syms.filter((s) => s.file === target || s.file.endsWith(target));
|
|
40
|
+
}
|
|
41
|
+
export function buildServer(root) {
|
|
42
|
+
const store = new HunchStore(hunchPaths(root));
|
|
43
|
+
// Ensure the SQLite index reflects the JSON source of truth on startup.
|
|
44
|
+
try {
|
|
45
|
+
store.reindex();
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
console.error("[hunch-mcp] reindex on startup failed:", e.message);
|
|
49
|
+
}
|
|
50
|
+
// Resolve the embedder ONCE for this long-lived process (never throws; null when
|
|
51
|
+
// the optional model isn't installed). The model then loads lazily on the first
|
|
52
|
+
// hunch_query and stays warm — and hybridSearch degrades to FTS until then.
|
|
53
|
+
const embedderReady = selectEmbedder();
|
|
54
|
+
const server = new McpServer({ name: "hunch", version: "0.1.0" });
|
|
55
|
+
// -- hunch_query ----------------------------------------------------------
|
|
56
|
+
server.registerTool("hunch_query", {
|
|
57
|
+
title: "Query Hunch",
|
|
58
|
+
description: "Full-text + graph search across the engineering memory (decisions, bugs, constraints, components, symbols). Returns ranked records with provenance. Use this to ask 'why' questions about the codebase.",
|
|
59
|
+
inputSchema: { query: z.string().describe("A natural-language question or keywords.") },
|
|
60
|
+
}, async ({ query }) => {
|
|
61
|
+
const hits = await store.hybridSearch(query, QUERY_HITS, { embedder: await embedderReady });
|
|
62
|
+
if (!hits.length)
|
|
63
|
+
return ok(`No matches for "${query}".`);
|
|
64
|
+
const lines = hits.map((h) => {
|
|
65
|
+
const r = store.resolve(h.ref);
|
|
66
|
+
return `• [${h.kind}] ${h.ref} — ${h.title}\n ${h.snippet}${provLine(r?.record)}`;
|
|
67
|
+
});
|
|
68
|
+
return ok(`Top matches for "${query}":\n\n${lines.join("\n")}`);
|
|
69
|
+
});
|
|
70
|
+
// -- hunch_why ------------------------------------------------------------
|
|
71
|
+
server.registerTool("hunch_why", {
|
|
72
|
+
title: "Explain why a file/symbol is the way it is",
|
|
73
|
+
description: "Return the decisions, bugs, and constraints that explain a file path or symbol — the 'why' and the 'what must not break', with evidence.",
|
|
74
|
+
inputSchema: { target: z.string().describe("A file path (e.g. src/auth/session.ts) or symbol name.") },
|
|
75
|
+
}, async ({ target }) => {
|
|
76
|
+
const w = store.why(target);
|
|
77
|
+
// Highest-signal first, then cap: invariants by severity, decisions by
|
|
78
|
+
// confidence, bugs by severity — so a hot file's trim drops the tail, not
|
|
79
|
+
// the records that matter most.
|
|
80
|
+
const decisions = [...w.decisions].sort((a, b) => (b.provenance.confidence ?? 0) - (a.provenance.confidence ?? 0));
|
|
81
|
+
const constraints = [...w.constraints].sort((a, b) => (SEV_CONSTRAINT[b.severity] ?? 0) - (SEV_CONSTRAINT[a.severity] ?? 0));
|
|
82
|
+
const bugs = [...w.bugs].sort((a, b) => (SEV_BUG[b.severity] ?? 0) - (SEV_BUG[a.severity] ?? 0));
|
|
83
|
+
const parts = [`Why for "${target}":`];
|
|
84
|
+
if (decisions.length)
|
|
85
|
+
parts.push(`\nDECISIONS:\n${decisions.slice(0, WHY_CAP).map((d) => ` • ${d.id} [${d.status}] ${d.title}\n ${d.decision}${provLine(d)}`).join("\n")}${more(decisions.length, WHY_CAP, "narrow the target")}`);
|
|
86
|
+
if (constraints.length)
|
|
87
|
+
parts.push(`\nCONSTRAINTS (must not break):\n${constraints.slice(0, WHY_CAP).map((c) => ` • ${c.id} [${c.severity}] ${c.statement}${provLine(c)}`).join("\n")}${more(constraints.length, WHY_CAP)}`);
|
|
88
|
+
if (bugs.length)
|
|
89
|
+
parts.push(`\nBUG HISTORY:\n${bugs.slice(0, WHY_CAP).map((b) => ` • ${b.id} [${b.status}/${b.severity}] ${b.title}\n root cause: ${b.root_cause}${provLine(b)}`).join("\n")}${more(bugs.length, WHY_CAP)}`);
|
|
90
|
+
if (w.components.length)
|
|
91
|
+
parts.push(`\nCOMPONENTS: ${w.components.map((c) => `${c.name} (${c.id})`).join(", ")}`);
|
|
92
|
+
if (w.symbols.length)
|
|
93
|
+
parts.push(`\nSYMBOLS: ${w.symbols.slice(0, WHY_CAP * 2).map((s) => `${s.name} [fan-in ${s.metrics.fan_in}, churn ${s.metrics.churn_90d}]`).join(", ")}${more(w.symbols.length, WHY_CAP * 2)}`);
|
|
94
|
+
if (parts.length === 1)
|
|
95
|
+
parts.push("\n(No recorded decisions/bugs/constraints yet for this target.)");
|
|
96
|
+
return ok(parts.join("\n"));
|
|
97
|
+
});
|
|
98
|
+
// -- hunch_bug_lineage ----------------------------------------------------
|
|
99
|
+
server.registerTool("hunch_bug_lineage", {
|
|
100
|
+
title: "Find related bugs and their lineage",
|
|
101
|
+
description: "Given a symptom description or a symbol, return matching bugs with their lineage (introduced → fixed → recurrence) so the agent doesn't re-discover past root causes.",
|
|
102
|
+
inputSchema: { symptom_or_symbol: z.string().describe("A symptom description or a symbol/file.") },
|
|
103
|
+
}, async ({ symptom_or_symbol }) => {
|
|
104
|
+
const bugs = store.bugLineage(symptom_or_symbol);
|
|
105
|
+
if (!bugs.length)
|
|
106
|
+
return ok(`No matching bugs for "${symptom_or_symbol}".`);
|
|
107
|
+
const lines = bugs.map((b) => {
|
|
108
|
+
const l = b.lineage;
|
|
109
|
+
return `• ${b.id} [${b.status}/${b.severity}] ${b.title}\n symptom: ${b.symptom}\n root cause: ${b.root_cause}\n lineage: introduced=${l.introduced_commit ?? "?"} fixed=${l.fixed_commit ?? "?"} recurrence_of=${l.recurrence_of ?? "—"} → decision=${l.spawned_decision ?? "—"} constraint=${l.spawned_constraint ?? "—"}${provLine(b)}`;
|
|
110
|
+
});
|
|
111
|
+
return ok(`Bugs related to "${symptom_or_symbol}":\n\n${lines.join("\n")}`);
|
|
112
|
+
});
|
|
113
|
+
// -- hunch_check_constraints ---------------------------------------------
|
|
114
|
+
server.registerTool("hunch_check_constraints", {
|
|
115
|
+
title: "Check invariants in scope",
|
|
116
|
+
description: "Return constraints whose scope matches a glob/path, sorted by severity. Call this BEFORE editing code to avoid breaking intentional invariants.",
|
|
117
|
+
inputSchema: { scope: z.string().describe("A path or glob, e.g. src/auth/** or src/auth/session.ts") },
|
|
118
|
+
}, async ({ scope }) => {
|
|
119
|
+
const cons = store.checkConstraints(scope);
|
|
120
|
+
if (!cons.length)
|
|
121
|
+
return ok(`No constraints in scope "${scope}".`);
|
|
122
|
+
const lines = cons.map((c) => `• ${c.id} [${c.severity}/${c.enforcement}] ${c.statement}\n rationale: ${c.rationale}${provLine(c)}`);
|
|
123
|
+
return ok(`Constraints affecting "${scope}":\n\n${lines.join("\n")}`);
|
|
124
|
+
});
|
|
125
|
+
// -- hunch_get_dependents -------------------------------------------------
|
|
126
|
+
server.registerTool("hunch_get_dependents", {
|
|
127
|
+
title: "Blast radius (transitive dependents)",
|
|
128
|
+
description: "Return everything that transitively depends on a symbol/component (callers + dependent components) so a change's blast radius is known before editing.",
|
|
129
|
+
inputSchema: { symbol: z.string().describe("A symbol id, symbol name, or file path.") },
|
|
130
|
+
}, async ({ symbol }) => {
|
|
131
|
+
const matches = resolveSymbols(store, symbol);
|
|
132
|
+
const ids = matches.length ? matches.map((s) => s.id) : [symbol];
|
|
133
|
+
const all = new Map();
|
|
134
|
+
for (const id of ids)
|
|
135
|
+
for (const d of store.getDependents(id))
|
|
136
|
+
if (!all.has(d.id))
|
|
137
|
+
all.set(d.id, d);
|
|
138
|
+
const deps = [...all.values()].sort((a, b) => a.depth - b.depth);
|
|
139
|
+
if (!deps.length)
|
|
140
|
+
return ok(`Nothing depends on "${symbol}" (leaf node, or not indexed).`);
|
|
141
|
+
// Nearest dependents first (sorted by depth); cap the tail so a high-fan-in
|
|
142
|
+
// symbol can't flood the session context.
|
|
143
|
+
const lines = deps.slice(0, DEP_CAP).map((d) => ` • [depth ${d.depth}] ${d.via} (${d.id})`);
|
|
144
|
+
return ok(`Blast radius of "${symbol}" — ${deps.length} dependent(s):\n${lines.join("\n")}${more(deps.length, DEP_CAP, "closest shown first")}`);
|
|
145
|
+
});
|
|
146
|
+
// -- hunch_context (surgical retrieval) -----------------------------------
|
|
147
|
+
server.registerTool("hunch_context", {
|
|
148
|
+
title: "Assemble the minimal relevant Hunch slice for a task",
|
|
149
|
+
description: "Given a file or symbol you're about to work on, return the MINIMAL relevant memory — invariants to preserve, decisions explaining the design, bug history not to reintroduce, and the blast radius — as a compact brief. Call this FIRST when starting work on something.",
|
|
150
|
+
inputSchema: {
|
|
151
|
+
target: z.string().describe("A file path or symbol you're about to edit."),
|
|
152
|
+
budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
|
|
153
|
+
},
|
|
154
|
+
}, async ({ target, budget_tokens }) => {
|
|
155
|
+
return ok(formatContext(store.assembleContext(target, budget_tokens ?? 1500)));
|
|
156
|
+
});
|
|
157
|
+
// -- hunch_record_decision (write-back) -----------------------------------
|
|
158
|
+
server.registerTool("hunch_record_decision", {
|
|
159
|
+
title: "Record a decision (write-back)",
|
|
160
|
+
description: "Persist a new Decision (ADR) into Hunch with provenance. Use after making a non-trivial design choice so future sessions are grounded in it.",
|
|
161
|
+
inputSchema: {
|
|
162
|
+
decision: z.object({
|
|
163
|
+
title: z.string(),
|
|
164
|
+
context: z.string().optional(),
|
|
165
|
+
decision: z.string().optional(),
|
|
166
|
+
consequences: z.array(z.string()).optional(),
|
|
167
|
+
alternatives_rejected: z.array(z.string()).optional(),
|
|
168
|
+
related_files: z.array(z.string()).optional(),
|
|
169
|
+
related_components: z.array(z.string()).optional(),
|
|
170
|
+
status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
|
|
171
|
+
commit: z.string().optional(),
|
|
172
|
+
}),
|
|
173
|
+
},
|
|
174
|
+
}, async ({ decision }) => {
|
|
175
|
+
try {
|
|
176
|
+
// Commit-keyed on the CANONICAL full sha (resolved via git rev-parse), so a
|
|
177
|
+
// human passing the short sha they see in `commit` produces the SAME id as
|
|
178
|
+
// the auto-sync path (which keys on the full sha) — UPGRADING the auto-draft
|
|
179
|
+
// instead of duplicating it. If the ref can't be resolved to a real full
|
|
180
|
+
// sha, fall back to the title/manual namespace so we never key on a raw,
|
|
181
|
+
// unverified string that could collide with (or orphan) a real commit id.
|
|
182
|
+
const resolved = decision.commit ? revParse(decision.commit, root) : null;
|
|
183
|
+
const fullSha = resolved && /^[0-9a-f]{40}$/.test(resolved) ? resolved : null;
|
|
184
|
+
const id = fullSha ? decisionId(fullSha) : decisionId(`manual:${decision.title}`);
|
|
185
|
+
// Preserve the ADR lineage: upgrading an auto-draft yields the composite
|
|
186
|
+
// provenance the design specifies.
|
|
187
|
+
const existing = store.json.get("decisions", id);
|
|
188
|
+
const source = existing && existing.provenance.source.includes("llm_draft")
|
|
189
|
+
? "llm_draft+human_confirmed"
|
|
190
|
+
: "human_confirmed";
|
|
191
|
+
const rec = {
|
|
192
|
+
id,
|
|
193
|
+
title: decision.title,
|
|
194
|
+
status: decision.status ?? "accepted",
|
|
195
|
+
context: decision.context ?? existing?.context ?? "",
|
|
196
|
+
decision: decision.decision ?? existing?.decision ?? "",
|
|
197
|
+
consequences: decision.consequences ?? [],
|
|
198
|
+
alternatives_rejected: decision.alternatives_rejected ?? [],
|
|
199
|
+
related_components: decision.related_components ?? existing?.related_components ?? [],
|
|
200
|
+
related_files: decision.related_files ?? existing?.related_files ?? [],
|
|
201
|
+
supersedes: existing?.supersedes ?? null,
|
|
202
|
+
caused_by_bug: existing?.caused_by_bug ?? null,
|
|
203
|
+
commit: decision.commit ?? existing?.commit ?? null,
|
|
204
|
+
provenance: { source, confidence: 0.95, evidence: decision.related_files ?? existing?.provenance.evidence ?? [] },
|
|
205
|
+
date: new Date().toISOString(),
|
|
206
|
+
};
|
|
207
|
+
store.json.put("decisions", rec);
|
|
208
|
+
store.reindex();
|
|
209
|
+
const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
|
|
210
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${note}`);
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
return err(`Failed to record decision: ${e.message}`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
return server;
|
|
217
|
+
}
|
|
218
|
+
function provLine(record) {
|
|
219
|
+
const p = record?.provenance;
|
|
220
|
+
if (!p)
|
|
221
|
+
return "";
|
|
222
|
+
const v = p.last_verified ? `, verified ${p.last_verified.slice(0, 10)}` : "";
|
|
223
|
+
return `\n ⟨${p.source ?? "?"}, confidence ${p.confidence ?? "?"}${v}⟩`;
|
|
224
|
+
}
|
|
225
|
+
/** Start the stdio server (called by `hunch mcp`). */
|
|
226
|
+
export async function startServer(cwd = process.cwd()) {
|
|
227
|
+
const root = findRoot(cwd);
|
|
228
|
+
const server = buildServer(root);
|
|
229
|
+
const transport = new StdioServerTransport();
|
|
230
|
+
await server.connect(transport);
|
|
231
|
+
console.error(`[hunch-mcp] serving Hunch at ${root} over stdio`);
|
|
232
|
+
}
|
|
233
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const DAY_MS = 86_400_000;
|
|
2
|
+
export function planCompaction(input, opts) {
|
|
3
|
+
const maxAgeDays = opts.maxAgeDays ?? 180;
|
|
4
|
+
const minConfidence = opts.minConfidence ?? 0.35;
|
|
5
|
+
const ageDays = (iso) => {
|
|
6
|
+
const t = Date.parse(iso);
|
|
7
|
+
return Number.isNaN(t) ? 0 : (opts.now - t) / DAY_MS; // unparseable date → treat as new (don't prune)
|
|
8
|
+
};
|
|
9
|
+
// Step 1: INTRINSIC low-value reason (ignoring cross-references). Curated records
|
|
10
|
+
// (accepted / human-confirmed / open bugs / constraints) are never candidates.
|
|
11
|
+
const decReason = (d) => {
|
|
12
|
+
if (d.status === "accepted" || d.provenance.source.includes("human_confirmed"))
|
|
13
|
+
return null;
|
|
14
|
+
const low = d.provenance.confidence < minConfidence;
|
|
15
|
+
const old = ageDays(d.date) >= maxAgeDays;
|
|
16
|
+
if (d.status === "rejected")
|
|
17
|
+
return "rejected draft";
|
|
18
|
+
if (d.status === "superseded" && old)
|
|
19
|
+
return `superseded, ${Math.round(ageDays(d.date))}d old`;
|
|
20
|
+
if (d.status === "proposed" && low && old)
|
|
21
|
+
return `stale low-confidence draft (${d.provenance.confidence}, ${Math.round(ageDays(d.date))}d)`;
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
const bugReason = (b) => {
|
|
25
|
+
if (b.provenance.source.includes("human_confirmed"))
|
|
26
|
+
return null;
|
|
27
|
+
// Only fixed, low-confidence bugs that did NOT anchor a lineage (spawned a
|
|
28
|
+
// constraint OR a decision) are prunable; open/investigating/regressed are kept.
|
|
29
|
+
if (b.status === "fixed" && b.provenance.confidence < minConfidence && !b.lineage.spawned_constraint && !b.lineage.spawned_decision) {
|
|
30
|
+
return "resolved low-confidence bug";
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
const decReasons = new Map();
|
|
35
|
+
for (const d of input.decisions) {
|
|
36
|
+
const r = decReason(d);
|
|
37
|
+
if (r)
|
|
38
|
+
decReasons.set(d.id, r);
|
|
39
|
+
}
|
|
40
|
+
const bugReasons = new Map();
|
|
41
|
+
for (const b of input.bugs) {
|
|
42
|
+
const r = bugReason(b);
|
|
43
|
+
if (r)
|
|
44
|
+
bugReasons.set(b.id, r);
|
|
45
|
+
}
|
|
46
|
+
// Step 2: greatest-fixpoint reference safety. Start with ALL candidates, then
|
|
47
|
+
// repeatedly drop any candidate that a SURVIVING record (one not currently slated
|
|
48
|
+
// for removal) still points to. References from records that are themselves being
|
|
49
|
+
// removed don't protect anything — so two dead drafts can't keep each other alive,
|
|
50
|
+
// and reference cycles among removable records resolve correctly.
|
|
51
|
+
const remDec = new Set(decReasons.keys());
|
|
52
|
+
const remBug = new Set(bugReasons.keys());
|
|
53
|
+
for (;;) {
|
|
54
|
+
const refDec = new Set();
|
|
55
|
+
const refBug = new Set();
|
|
56
|
+
for (const d of input.decisions) {
|
|
57
|
+
if (remDec.has(d.id))
|
|
58
|
+
continue; // d is being removed → its references don't count
|
|
59
|
+
if (d.supersedes)
|
|
60
|
+
refDec.add(d.supersedes);
|
|
61
|
+
if (d.caused_by_bug)
|
|
62
|
+
refBug.add(d.caused_by_bug);
|
|
63
|
+
}
|
|
64
|
+
for (const b of input.bugs) {
|
|
65
|
+
if (remBug.has(b.id))
|
|
66
|
+
continue;
|
|
67
|
+
if (b.lineage.recurrence_of)
|
|
68
|
+
refBug.add(b.lineage.recurrence_of);
|
|
69
|
+
if (b.lineage.spawned_decision)
|
|
70
|
+
refDec.add(b.lineage.spawned_decision);
|
|
71
|
+
}
|
|
72
|
+
for (const c of input.constraints) {
|
|
73
|
+
if (c.source_decision)
|
|
74
|
+
refDec.add(c.source_decision); // constraints are never removed → always survivors
|
|
75
|
+
}
|
|
76
|
+
let changed = false;
|
|
77
|
+
for (const id of [...remDec])
|
|
78
|
+
if (refDec.has(id)) {
|
|
79
|
+
remDec.delete(id);
|
|
80
|
+
changed = true;
|
|
81
|
+
}
|
|
82
|
+
for (const id of [...remBug])
|
|
83
|
+
if (refBug.has(id)) {
|
|
84
|
+
remBug.delete(id);
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
if (!changed)
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
const remove = [];
|
|
91
|
+
for (const d of input.decisions)
|
|
92
|
+
if (remDec.has(d.id))
|
|
93
|
+
remove.push({ kind: "decisions", id: d.id, title: d.title, reason: decReasons.get(d.id) });
|
|
94
|
+
for (const b of input.bugs)
|
|
95
|
+
if (remBug.has(b.id))
|
|
96
|
+
remove.push({ kind: "bugs", id: b.id, title: b.title, reason: bugReasons.get(b.id) });
|
|
97
|
+
// constraints are invariants — intentionally never auto-removed.
|
|
98
|
+
return { remove, considered: input.decisions.length + input.bugs.length };
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=compact.js.map
|
package/dist/store/db.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Thin wrapper around better-sqlite3 for the derived index. */
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { SCHEMA_SQL } from "./schema.js";
|
|
6
|
+
export function openDb(sqlitePath) {
|
|
7
|
+
mkdirSync(dirname(sqlitePath), { recursive: true });
|
|
8
|
+
const db = new Database(sqlitePath);
|
|
9
|
+
db.pragma("busy_timeout = 5000");
|
|
10
|
+
db.exec(SCHEMA_SQL);
|
|
11
|
+
return db;
|
|
12
|
+
}
|
|
13
|
+
/** In-memory db (tests / ephemeral queries). */
|
|
14
|
+
export function openMemoryDb() {
|
|
15
|
+
const db = new Database(":memory:");
|
|
16
|
+
db.exec(SCHEMA_SQL);
|
|
17
|
+
return db;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=db.js.map
|