@davesheffer/hunch 0.12.0 → 0.12.2
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 +379 -345
- package/dist/cli/index.js +12 -6
- package/dist/cli/invocation.js +19 -2
- package/dist/core/paths.js +8 -0
- package/dist/extractors/git.js +6 -0
- package/dist/integrations/ciAction.js +19 -6
- package/dist/integrations/scaffold.js +30 -30
- package/dist/mcp/server.js +5 -4
- package/dist/store/hunchStore.js +54 -39
- package/dist/store/schema.js +79 -79
- package/dist/synthesis/provider.js +4 -4
- package/package.json +68 -68
package/dist/cli/index.js
CHANGED
|
@@ -17,14 +17,14 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
17
17
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
18
18
|
import { relative } from "node:path";
|
|
19
19
|
import { Command } from "commander";
|
|
20
|
-
import { hunchPaths, findRoot } from "../core/paths.js";
|
|
20
|
+
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
21
21
|
import { HunchStore } from "../store/hunchStore.js";
|
|
22
22
|
import { selectEmbedder } from "../store/embedder.js";
|
|
23
23
|
import { indexRepo } from "../extractors/indexer.js";
|
|
24
24
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
25
25
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
26
26
|
import { selectProvider } from "../synthesis/provider.js";
|
|
27
|
-
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff } from "../extractors/git.js";
|
|
27
|
+
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists } from "../extractors/git.js";
|
|
28
28
|
import { analyzeDiff } from "../extractors/diff.js";
|
|
29
29
|
import { isStrictBlocker } from "../core/strictgate.js";
|
|
30
30
|
import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
|
|
@@ -55,7 +55,7 @@ function storeFor() {
|
|
|
55
55
|
// ---- init -----------------------------------------------------------------
|
|
56
56
|
program
|
|
57
57
|
.command("init")
|
|
58
|
-
.description("Scaffold .hunch/, index the repo, install the git hook, and wire up Claude Code.")
|
|
58
|
+
.description("Scaffold .hunch/, index the repo, install the git hook, and wire up your coding assistants (Claude Code, Cursor, VS Code, Windsurf, Codex).")
|
|
59
59
|
.option("--no-index", "skip the initial repo index")
|
|
60
60
|
.option("--no-enforce", "do not install the advisory pre-commit constraint guard")
|
|
61
61
|
.option("--enforce-strict", "make the pre-commit guard FAIL the commit on a direct, high-confidence, non-stale blocking invariant")
|
|
@@ -133,7 +133,7 @@ program
|
|
|
133
133
|
console.log(` ⚠ skipped ${p.assistant}: ${p.error}`);
|
|
134
134
|
}
|
|
135
135
|
store.close();
|
|
136
|
-
console.log("\nNext: make a commit (the hook captures a decision), then ask
|
|
136
|
+
console.log("\nNext: make a commit (the hook captures a decision), then ask your coding assistant \"why is X built this way?\"");
|
|
137
137
|
console.log("Cold start? Seed from history: hunch backfill --since 90d");
|
|
138
138
|
});
|
|
139
139
|
// ---- index ----------------------------------------------------------------
|
|
@@ -409,7 +409,7 @@ program
|
|
|
409
409
|
return fail(`--severity must be one of: ${SEV.join(", ")}`);
|
|
410
410
|
const { store, root } = storeFor();
|
|
411
411
|
store.json.ensureDirs();
|
|
412
|
-
const scope = opts.scope.split(",").map((s) => s.trim()).filter(Boolean);
|
|
412
|
+
const scope = opts.scope.split(",").map((s) => toPosixTarget(s.trim())).filter(Boolean);
|
|
413
413
|
const c = store.json.put("constraints", {
|
|
414
414
|
id: constraintId(statement),
|
|
415
415
|
type: opts.type,
|
|
@@ -550,6 +550,12 @@ program
|
|
|
550
550
|
const markdown = opts.format === "markdown";
|
|
551
551
|
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], strictBlockers: 0, regBlocking: 0 };
|
|
552
552
|
const { store, root } = storeFor();
|
|
553
|
+
// Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
|
|
554
|
+
// branch) — otherwise the diff is empty and the guard passes vacuously.
|
|
555
|
+
if (opts.base && !revExists(opts.base, root)) {
|
|
556
|
+
store.close();
|
|
557
|
+
return fail(`--base ref "${opts.base}" does not resolve. In CI, fetch the base branch first (git fetch origin <branch>).`);
|
|
558
|
+
}
|
|
553
559
|
store.reindex(); // blast radius walks the edge graph — make the index current
|
|
554
560
|
const files = opts.commit ? commitFiles(opts.commit, root)
|
|
555
561
|
: opts.base ? rangeFiles(opts.base, root)
|
|
@@ -593,7 +599,7 @@ program
|
|
|
593
599
|
return {
|
|
594
600
|
id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
|
|
595
601
|
files: fs, strictBlocks,
|
|
596
|
-
downgrade:
|
|
602
|
+
downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
|
|
597
603
|
};
|
|
598
604
|
});
|
|
599
605
|
const report = {
|
package/dist/cli/invocation.js
CHANGED
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
/** Figures out how to re-invoke this CLI from a git hook / .mcp.json, working
|
|
2
2
|
* both when running the built dist (plain node) and in dev via tsx. */
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
/** Published package name — used for OS-agnostic invocations (see below). */
|
|
5
|
+
const PKG = "@davesheffer/hunch";
|
|
4
6
|
export function resolveInvocation() {
|
|
5
7
|
const entry = fileURLToPath(import.meta.url).replace(/invocation\.(js|ts)$/, "index.$1");
|
|
6
8
|
const isDev = entry.endsWith(".ts");
|
|
7
9
|
// JSON.stringify yields a double-quoted, backslash-escaped token /bin/sh
|
|
8
10
|
// accepts — so install paths with spaces don't break the hook command.
|
|
9
11
|
const q = (s) => JSON.stringify(s);
|
|
12
|
+
// Running from an installed copy (global, local, or npx cache — i.e. NOT a
|
|
13
|
+
// source checkout we're hacking on). The MCP/provider config files we write
|
|
14
|
+
// are committed and shared across a team via git, so they must NOT embed this
|
|
15
|
+
// machine's absolute path or OS-specific separators. Reference Hunch by its
|
|
16
|
+
// published package name instead, which `npx` resolves the same on any OS and
|
|
17
|
+
// any clone. The git hook lives in per-machine .git/hooks (never committed),
|
|
18
|
+
// so it keeps the PATH-robust absolute-node invocation below.
|
|
19
|
+
const installed = !isDev && entry.replace(/\\/g, "/").includes("/node_modules/");
|
|
20
|
+
if (installed) {
|
|
21
|
+
return {
|
|
22
|
+
shell: `${q(process.execPath)} ${q(entry)}`,
|
|
23
|
+
mcp: { command: "npx", args: ["-y", PKG] },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
10
26
|
if (isDev) {
|
|
11
27
|
return {
|
|
12
28
|
shell: `npx tsx ${q(entry)}`,
|
|
13
29
|
mcp: { command: "npx", args: ["tsx", entry] },
|
|
14
30
|
};
|
|
15
31
|
}
|
|
16
|
-
//
|
|
17
|
-
//
|
|
32
|
+
// Source-checkout dist run (e.g. `node dist/cli/index.js`, npm link): inherently
|
|
33
|
+
// per-machine. Use the absolute node binary (process.execPath) rather than a bare
|
|
34
|
+
// `node`, so the hook works even when nvm's `node` isn't on the hook's PATH.
|
|
18
35
|
return {
|
|
19
36
|
shell: `${q(process.execPath)} ${q(entry)}`,
|
|
20
37
|
mcp: { command: process.execPath, args: [entry] },
|
package/dist/core/paths.js
CHANGED
|
@@ -3,6 +3,14 @@ import { join } from "node:path";
|
|
|
3
3
|
import { existsSync, statSync } from "node:fs";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
5
|
export const HUNCH_DIR = ".hunch";
|
|
6
|
+
/** Canonicalize a free-form path/target to repo-relative POSIX form. Hunch stores
|
|
7
|
+
* every path with forward slashes (git emits "/" on all OSes), so any user- or
|
|
8
|
+
* agent-supplied target must be normalized before comparison — otherwise a
|
|
9
|
+
* Windows caller passing `src\auth\session.ts` never matches the stored
|
|
10
|
+
* `src/auth/session.ts`. Safe on symbol names too: they contain no backslashes. */
|
|
11
|
+
export function toPosixTarget(target) {
|
|
12
|
+
return target.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
13
|
+
}
|
|
6
14
|
export function hunchPaths(root) {
|
|
7
15
|
const hunch = join(root, HUNCH_DIR);
|
|
8
16
|
return {
|
package/dist/extractors/git.js
CHANGED
|
@@ -153,6 +153,12 @@ export function stagedFiles(cwd) {
|
|
|
153
153
|
const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
154
154
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
155
155
|
}
|
|
156
|
+
/** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
|
|
157
|
+
* unfetched/typo'd ref instead of silently diffing against nothing (a vacuous
|
|
158
|
+
* CI pass), since the diff helpers below swallow git errors to "". */
|
|
159
|
+
export function revExists(ref, cwd) {
|
|
160
|
+
return gitSafe(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd) !== "";
|
|
161
|
+
}
|
|
156
162
|
/** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
|
|
157
163
|
* i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
|
|
158
164
|
export function rangeFiles(base, cwd, head = "HEAD") {
|
|
@@ -38,6 +38,12 @@ jobs:
|
|
|
38
38
|
- name: Install Hunch
|
|
39
39
|
run: npm install -g @davesheffer/hunch
|
|
40
40
|
|
|
41
|
+
- name: Fetch the PR base branch
|
|
42
|
+
# checkout sets up no origin/<base> tracking ref; create it explicitly so
|
|
43
|
+
# the guard's base...head diff resolves (otherwise it sees zero changes and
|
|
44
|
+
# passes vacuously).
|
|
45
|
+
run: git fetch --no-tags origin "+refs/heads/\${{ github.base_ref }}:refs/remotes/origin/\${{ github.base_ref }}"
|
|
46
|
+
|
|
41
47
|
- name: Run Constraint Guard
|
|
42
48
|
id: guard
|
|
43
49
|
run: |
|
|
@@ -52,19 +58,26 @@ jobs:
|
|
|
52
58
|
with:
|
|
53
59
|
script: |
|
|
54
60
|
const fs = require('fs');
|
|
55
|
-
const body = fs.readFileSync('hunch-report.md', 'utf8').trim();
|
|
61
|
+
const body = (fs.existsSync('hunch-report.md') ? fs.readFileSync('hunch-report.md', 'utf8') : '').trim();
|
|
62
|
+
if (!body) { core.info('Hunch: empty report — skipping comment.'); return; }
|
|
56
63
|
const marker = '<!-- hunch-guard -->';
|
|
57
64
|
const { owner, repo } = context.repo;
|
|
58
65
|
const issue_number = context.payload.pull_request.number;
|
|
59
|
-
const all = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
|
|
60
|
-
const existing = all.find(c => c.body && c.body.includes(marker));
|
|
61
66
|
const out = marker + '\\n' + body;
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
try {
|
|
68
|
+
const all = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
|
|
69
|
+
const existing = all.find(c => c.body && c.body.includes(marker));
|
|
70
|
+
if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: out });
|
|
71
|
+
else await github.rest.issues.createComment({ owner, repo, issue_number, body: out });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
core.warning('Hunch: could not post PR comment (fork PR has a read-only token?): ' + e.message);
|
|
74
|
+
}
|
|
64
75
|
|
|
65
76
|
- name: Enforce (fail on a blocking invariant)
|
|
77
|
+
# Default to 1 if the guard step died before recording its exit — never a
|
|
78
|
+
# vacuous pass.
|
|
66
79
|
if: always()
|
|
67
|
-
run: exit \${{ steps.guard.outputs.exit }}
|
|
80
|
+
run: exit \${{ steps.guard.outputs.exit || '1' }}
|
|
68
81
|
`;
|
|
69
82
|
}
|
|
70
83
|
/** Write .github/workflows/hunch-guard.yml. Never overwrites an existing file
|
|
@@ -22,38 +22,38 @@ export function writeMcpJson(root, inv) {
|
|
|
22
22
|
writeFileSync(file, JSON.stringify(json, null, 2) + "\n");
|
|
23
23
|
return file;
|
|
24
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\`.
|
|
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
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.
|
|
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
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.
|
|
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
57
|
`;
|
|
58
58
|
/** A settings.json hook entry is Hunch's if any of its commands ends with the
|
|
59
59
|
* Hunch CLI entry + the `hook` subcommand (e.g. `…/index.js hook`). Matching the
|
package/dist/mcp/server.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
11
|
import { z } from "zod";
|
|
12
|
-
import { hunchPaths, findRoot } from "../core/paths.js";
|
|
12
|
+
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
13
13
|
import { HunchStore } from "../store/hunchStore.js";
|
|
14
14
|
import { selectEmbedder } from "../store/embedder.js";
|
|
15
15
|
import { decisionId } from "../core/ids.js";
|
|
@@ -29,6 +29,7 @@ const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
|
29
29
|
const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
|
|
30
30
|
/** Resolve a free-form target (symbol id / name / file path) to symbol records. */
|
|
31
31
|
function resolveSymbols(store, target) {
|
|
32
|
+
target = toPosixTarget(target);
|
|
32
33
|
const syms = store.json.loadAll("symbols");
|
|
33
34
|
const byId = syms.find((s) => s.id === target);
|
|
34
35
|
if (byId)
|
|
@@ -42,7 +43,7 @@ function resolveSymbols(store, target) {
|
|
|
42
43
|
* radius). Falls back to the literal target so direct-scope checks still run. */
|
|
43
44
|
function resolveFiles(store, target) {
|
|
44
45
|
const files = new Set(resolveSymbols(store, target).map((s) => s.file));
|
|
45
|
-
return files.size ? [...files] : [target];
|
|
46
|
+
return files.size ? [...files] : [toPosixTarget(target)];
|
|
46
47
|
}
|
|
47
48
|
export function buildServer(root) {
|
|
48
49
|
const store = new HunchStore(hunchPaths(root));
|
|
@@ -261,7 +262,7 @@ export function buildServer(root) {
|
|
|
261
262
|
consequences: decision.consequences ?? [],
|
|
262
263
|
alternatives_rejected: decision.alternatives_rejected ?? [],
|
|
263
264
|
related_components: decision.related_components ?? existing?.related_components ?? [],
|
|
264
|
-
related_files: decision.related_files ?? existing?.related_files ?? [],
|
|
265
|
+
related_files: (decision.related_files ?? existing?.related_files ?? []).map(toPosixTarget),
|
|
265
266
|
supersedes: decision.supersedes ?? existing?.supersedes ?? null,
|
|
266
267
|
superseded_by: existing?.superseded_by ?? null,
|
|
267
268
|
caused_by_bug: existing?.caused_by_bug ?? null,
|
|
@@ -269,7 +270,7 @@ export function buildServer(root) {
|
|
|
269
270
|
valid_from: existing?.valid_from ?? now,
|
|
270
271
|
valid_to: existing?.valid_to ?? null,
|
|
271
272
|
retired: existing?.retired ?? { symbols: [], deps: [] },
|
|
272
|
-
provenance: { source, confidence: 0.95, evidence: decision.related_files ?? existing?.provenance.evidence ?? [] },
|
|
273
|
+
provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
|
|
273
274
|
date: now,
|
|
274
275
|
};
|
|
275
276
|
store.json.put("decisions", rec);
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HunchStore — the read/write query layer over the JSON source of truth and the
|
|
3
|
+
* SQLite derived index. Everything the CLI and MCP server need flows through here.
|
|
4
|
+
*
|
|
5
|
+
* - reindex(): JSON -> SQLite (rebuild the derived index + FTS)
|
|
6
|
+
* - search(): FTS5 ranked query (hunch_query)
|
|
7
|
+
* - why(): decisions/bugs/constraints explaining a path/symbol
|
|
8
|
+
* - getDependents(): recursive-CTE blast radius over the call/dep graph
|
|
9
|
+
* - checkConstraints(): constraints whose scope matches a glob/path
|
|
10
|
+
* - bugLineage(): bugs matching a symptom/symbol + their lineage
|
|
11
|
+
* - fragility(): ranked fragility report with evidence
|
|
12
|
+
*/
|
|
13
|
+
import { toPosixTarget } from "../core/paths.js";
|
|
1
14
|
import { ENTITY_KINDS } from "../core/types.js";
|
|
2
15
|
import { openDb } from "./db.js";
|
|
3
16
|
import { RESET_SQL, embedHash } from "./schema.js";
|
|
@@ -112,7 +125,7 @@ export class HunchStore {
|
|
|
112
125
|
if (!match)
|
|
113
126
|
return this.likeSearch(query, limit);
|
|
114
127
|
try {
|
|
115
|
-
const rows = this.db.prepare(`SELECT ref, kind, title, snippet(search, 3, '[', ']', '…', 12) AS snip, bm25(search) AS score
|
|
128
|
+
const rows = this.db.prepare(`SELECT ref, kind, title, snippet(search, 3, '[', ']', '…', 12) AS snip, bm25(search) AS score
|
|
116
129
|
FROM search WHERE search MATCH ? ORDER BY score LIMIT ?`).all(match, limit);
|
|
117
130
|
return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score }));
|
|
118
131
|
}
|
|
@@ -124,7 +137,7 @@ export class HunchStore {
|
|
|
124
137
|
/** Substring fallback over titles/bodies (handles non-ASCII / malformed FTS). */
|
|
125
138
|
likeSearch(query, limit) {
|
|
126
139
|
const like = `%${query.replace(/[%_]/g, "")}%`;
|
|
127
|
-
const rows = this.db.prepare(`SELECT ref, kind, title, substr(body,1,120) AS snip FROM search
|
|
140
|
+
const rows = this.db.prepare(`SELECT ref, kind, title, substr(body,1,120) AS snip FROM search
|
|
128
141
|
WHERE title LIKE ? OR body LIKE ? LIMIT ?`).all(like, like, limit);
|
|
129
142
|
return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: 0 }));
|
|
130
143
|
}
|
|
@@ -281,6 +294,7 @@ export class HunchStore {
|
|
|
281
294
|
* instant — "what did we believe as of commit X?". Omit `asOf` for the full,
|
|
282
295
|
* history-inclusive view (backward-compatible default). */
|
|
283
296
|
why(target, opts = {}) {
|
|
297
|
+
target = toPosixTarget(target);
|
|
284
298
|
const decisions = this.json.loadAll("decisions");
|
|
285
299
|
const bugs = this.json.loadAll("bugs");
|
|
286
300
|
const constraints = this.json.loadAll("constraints");
|
|
@@ -308,37 +322,37 @@ export class HunchStore {
|
|
|
308
322
|
* walk edges BACKWARD (edges.to = current) following call/dep/import/contains. */
|
|
309
323
|
getDependents(id, maxDepth = 6) {
|
|
310
324
|
return this.db.prepare(
|
|
311
|
-
/* sql */ `
|
|
312
|
-
WITH RECURSIVE up(node, depth) AS (
|
|
313
|
-
SELECT ?, 0
|
|
314
|
-
UNION
|
|
315
|
-
SELECT e."from", up.depth + 1
|
|
316
|
-
FROM edges e JOIN up ON e."to" = up.node
|
|
317
|
-
WHERE e.type IN ('calls','depends_on','imports','contains') AND up.depth < ?
|
|
318
|
-
)
|
|
319
|
-
SELECT up.node AS id, MIN(up.depth) AS depth,
|
|
320
|
-
COALESCE(s.name || ' @ ' || s.file, c.name, up.node) AS via
|
|
321
|
-
FROM up
|
|
322
|
-
LEFT JOIN symbols s ON s.id = up.node
|
|
323
|
-
LEFT JOIN components c ON c.id = up.node
|
|
325
|
+
/* sql */ `
|
|
326
|
+
WITH RECURSIVE up(node, depth) AS (
|
|
327
|
+
SELECT ?, 0
|
|
328
|
+
UNION
|
|
329
|
+
SELECT e."from", up.depth + 1
|
|
330
|
+
FROM edges e JOIN up ON e."to" = up.node
|
|
331
|
+
WHERE e.type IN ('calls','depends_on','imports','contains') AND up.depth < ?
|
|
332
|
+
)
|
|
333
|
+
SELECT up.node AS id, MIN(up.depth) AS depth,
|
|
334
|
+
COALESCE(s.name || ' @ ' || s.file, c.name, up.node) AS via
|
|
335
|
+
FROM up
|
|
336
|
+
LEFT JOIN symbols s ON s.id = up.node
|
|
337
|
+
LEFT JOIN components c ON c.id = up.node
|
|
324
338
|
WHERE up.node <> ? GROUP BY up.node ORDER BY depth, id`).all(id, maxDepth, id);
|
|
325
339
|
}
|
|
326
340
|
/** Symbols/components this id depends ON (forward walk) — used for refactor blast radius. */
|
|
327
341
|
getDependencies(id, maxDepth = 6) {
|
|
328
342
|
return this.db.prepare(
|
|
329
|
-
/* sql */ `
|
|
330
|
-
WITH RECURSIVE down(node, depth) AS (
|
|
331
|
-
SELECT ?, 0
|
|
332
|
-
UNION
|
|
333
|
-
SELECT e."to", down.depth + 1
|
|
334
|
-
FROM edges e JOIN down ON e."from" = down.node
|
|
335
|
-
WHERE e.type IN ('calls','depends_on','imports','contains') AND down.depth < ?
|
|
336
|
-
)
|
|
337
|
-
SELECT down.node AS id, MIN(down.depth) AS depth,
|
|
338
|
-
COALESCE(s.name || ' @ ' || s.file, c.name, down.node) AS via
|
|
339
|
-
FROM down
|
|
340
|
-
LEFT JOIN symbols s ON s.id = down.node
|
|
341
|
-
LEFT JOIN components c ON c.id = down.node
|
|
343
|
+
/* sql */ `
|
|
344
|
+
WITH RECURSIVE down(node, depth) AS (
|
|
345
|
+
SELECT ?, 0
|
|
346
|
+
UNION
|
|
347
|
+
SELECT e."to", down.depth + 1
|
|
348
|
+
FROM edges e JOIN down ON e."from" = down.node
|
|
349
|
+
WHERE e.type IN ('calls','depends_on','imports','contains') AND down.depth < ?
|
|
350
|
+
)
|
|
351
|
+
SELECT down.node AS id, MIN(down.depth) AS depth,
|
|
352
|
+
COALESCE(s.name || ' @ ' || s.file, c.name, down.node) AS via
|
|
353
|
+
FROM down
|
|
354
|
+
LEFT JOIN symbols s ON s.id = down.node
|
|
355
|
+
LEFT JOIN components c ON c.id = down.node
|
|
342
356
|
WHERE down.node <> ? GROUP BY down.node ORDER BY depth, id`).all(id, maxDepth, id);
|
|
343
357
|
}
|
|
344
358
|
/** Files whose symbols (in)directly DEPEND ON a symbol defined in `file` — the
|
|
@@ -350,17 +364,17 @@ export class HunchStore {
|
|
|
350
364
|
// the bare `via` take the name from the nearest-depth row. The inner JOIN drops
|
|
351
365
|
// non-symbol nodes; `s.file <> ?` drops self-file dependents.
|
|
352
366
|
return this.db.prepare(
|
|
353
|
-
/* sql */ `
|
|
354
|
-
WITH RECURSIVE up(node, depth) AS (
|
|
355
|
-
SELECT id, 0 FROM symbols WHERE file = ?
|
|
356
|
-
UNION
|
|
357
|
-
SELECT e."from", up.depth + 1
|
|
358
|
-
FROM edges e JOIN up ON e."to" = up.node
|
|
359
|
-
WHERE e.type IN ('calls','depends_on','imports','contains') AND up.depth < ?
|
|
360
|
-
)
|
|
361
|
-
SELECT s.file AS file, s.name AS via, MIN(up.depth) AS depth
|
|
362
|
-
FROM up JOIN symbols s ON s.id = up.node
|
|
363
|
-
WHERE up.depth > 0 AND s.file <> ?
|
|
367
|
+
/* sql */ `
|
|
368
|
+
WITH RECURSIVE up(node, depth) AS (
|
|
369
|
+
SELECT id, 0 FROM symbols WHERE file = ?
|
|
370
|
+
UNION
|
|
371
|
+
SELECT e."from", up.depth + 1
|
|
372
|
+
FROM edges e JOIN up ON e."to" = up.node
|
|
373
|
+
WHERE e.type IN ('calls','depends_on','imports','contains') AND up.depth < ?
|
|
374
|
+
)
|
|
375
|
+
SELECT s.file AS file, s.name AS via, MIN(up.depth) AS depth
|
|
376
|
+
FROM up JOIN symbols s ON s.id = up.node
|
|
377
|
+
WHERE up.depth > 0 AND s.file <> ?
|
|
364
378
|
GROUP BY s.file ORDER BY depth, file`).all(file, maxDepth, file);
|
|
365
379
|
}
|
|
366
380
|
/** Constraints whose scope glob matches a path/glob (hunch_check_constraints).
|
|
@@ -558,6 +572,7 @@ export class HunchStore {
|
|
|
558
572
|
* a task on `target`, ordered by what matters most — invariants first, then the
|
|
559
573
|
* why, then blast radius and bug history — trimmed to a rough token budget. */
|
|
560
574
|
assembleContext(target, budget = 1500, opts = {}) {
|
|
575
|
+
target = toPosixTarget(target);
|
|
561
576
|
const w = this.why(target, opts);
|
|
562
577
|
const symIds = w.symbols.map((s) => s.id);
|
|
563
578
|
const blast = new Map();
|
package/dist/store/schema.js
CHANGED
|
@@ -15,87 +15,87 @@ import { shortHash } from "../core/ids.js";
|
|
|
15
15
|
export function embedHash(title, body) {
|
|
16
16
|
return shortHash(`${title}\x00${body ?? ""}`, 16);
|
|
17
17
|
}
|
|
18
|
-
export const SCHEMA_SQL = /* sql */ `
|
|
19
|
-
PRAGMA journal_mode = WAL;
|
|
20
|
-
PRAGMA foreign_keys = OFF;
|
|
21
|
-
|
|
22
|
-
CREATE TABLE IF NOT EXISTS components (
|
|
23
|
-
id TEXT PRIMARY KEY,
|
|
24
|
-
kind TEXT, name TEXT, responsibility TEXT,
|
|
25
|
-
paths TEXT, status TEXT, owners TEXT,
|
|
26
|
-
fragility REAL,
|
|
27
|
-
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
|
|
28
|
-
created_at TEXT, updated_at TEXT
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
CREATE TABLE IF NOT EXISTS edges (
|
|
32
|
-
id TEXT PRIMARY KEY,
|
|
33
|
-
"from" TEXT, "to" TEXT, type TEXT, reason TEXT, strength REAL,
|
|
34
|
-
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
35
|
-
);
|
|
36
|
-
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges("from");
|
|
37
|
-
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges("to");
|
|
38
|
-
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
|
|
39
|
-
|
|
40
|
-
CREATE TABLE IF NOT EXISTS symbols (
|
|
41
|
-
id TEXT PRIMARY KEY,
|
|
42
|
-
file TEXT, name TEXT, kind TEXT, signature_hash TEXT,
|
|
43
|
-
calls TEXT, called_by TEXT,
|
|
44
|
-
loc INTEGER, churn_90d INTEGER, bug_count INTEGER, fan_in INTEGER, fan_out INTEGER,
|
|
45
|
-
last_changed TEXT
|
|
46
|
-
);
|
|
47
|
-
CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file);
|
|
48
|
-
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
|
|
49
|
-
|
|
50
|
-
CREATE TABLE IF NOT EXISTS decisions (
|
|
51
|
-
id TEXT PRIMARY KEY,
|
|
52
|
-
title TEXT, status TEXT, context TEXT, decision TEXT,
|
|
53
|
-
consequences TEXT, alternatives_rejected TEXT,
|
|
54
|
-
related_components TEXT, related_files TEXT,
|
|
55
|
-
supersedes TEXT, caused_by_bug TEXT, "commit" TEXT,
|
|
56
|
-
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
|
|
57
|
-
date TEXT
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
CREATE TABLE IF NOT EXISTS bugs (
|
|
61
|
-
id TEXT PRIMARY KEY,
|
|
62
|
-
title TEXT, symptom TEXT, root_cause TEXT, severity TEXT, status TEXT,
|
|
63
|
-
affected_files TEXT, affected_symbols TEXT, lineage TEXT,
|
|
64
|
-
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
CREATE TABLE IF NOT EXISTS constraints (
|
|
68
|
-
id TEXT PRIMARY KEY,
|
|
69
|
-
type TEXT, statement TEXT, scope TEXT, severity TEXT, enforcement TEXT,
|
|
70
|
-
rationale TEXT, source_decision TEXT, violations TEXT,
|
|
71
|
-
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
-- Unified full-text search across every entity. Rebuilt on index; bm25-ranked.
|
|
75
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
|
|
76
|
-
ref UNINDEXED, -- entity id
|
|
77
|
-
kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints
|
|
78
|
-
title,
|
|
79
|
-
body,
|
|
80
|
-
tokenize = 'porter unicode61'
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
-- Local semantic-search vectors (opt-in; written by \`hunch embed\`). One row per
|
|
84
|
-
-- (ref, model); vec is a Float32 BLOB. DELIBERATELY NOT in RESET_SQL: reindex()
|
|
85
|
-
-- runs RESET on nearly every path (MCP startup, every query/context), so resetting
|
|
86
|
-
-- embeddings here would wipe them constantly and make the feature a no-op. Staleness
|
|
87
|
-
-- is tracked by doc_hash and reconciled by pruneStaleEmbeddings() instead. Recall is
|
|
88
|
-
-- exact brute-force cosine in JS (graphs are small); sqlite-vec only past ~100k rows.
|
|
89
|
-
CREATE TABLE IF NOT EXISTS embeddings (
|
|
90
|
-
ref TEXT, kind TEXT, model TEXT, dim INTEGER, doc_hash TEXT, vec BLOB,
|
|
91
|
-
PRIMARY KEY (ref, model)
|
|
92
|
-
);
|
|
18
|
+
export const SCHEMA_SQL = /* sql */ `
|
|
19
|
+
PRAGMA journal_mode = WAL;
|
|
20
|
+
PRAGMA foreign_keys = OFF;
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS components (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
kind TEXT, name TEXT, responsibility TEXT,
|
|
25
|
+
paths TEXT, status TEXT, owners TEXT,
|
|
26
|
+
fragility REAL,
|
|
27
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
|
|
28
|
+
created_at TEXT, updated_at TEXT
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
"from" TEXT, "to" TEXT, type TEXT, reason TEXT, strength REAL,
|
|
34
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges("from");
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges("to");
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
file TEXT, name TEXT, kind TEXT, signature_hash TEXT,
|
|
43
|
+
calls TEXT, called_by TEXT,
|
|
44
|
+
loc INTEGER, churn_90d INTEGER, bug_count INTEGER, fan_in INTEGER, fan_out INTEGER,
|
|
45
|
+
last_changed TEXT
|
|
46
|
+
);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
title TEXT, status TEXT, context TEXT, decision TEXT,
|
|
53
|
+
consequences TEXT, alternatives_rejected TEXT,
|
|
54
|
+
related_components TEXT, related_files TEXT,
|
|
55
|
+
supersedes TEXT, caused_by_bug TEXT, "commit" TEXT,
|
|
56
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
|
|
57
|
+
date TEXT
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
CREATE TABLE IF NOT EXISTS bugs (
|
|
61
|
+
id TEXT PRIMARY KEY,
|
|
62
|
+
title TEXT, symptom TEXT, root_cause TEXT, severity TEXT, status TEXT,
|
|
63
|
+
affected_files TEXT, affected_symbols TEXT, lineage TEXT,
|
|
64
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS constraints (
|
|
68
|
+
id TEXT PRIMARY KEY,
|
|
69
|
+
type TEXT, statement TEXT, scope TEXT, severity TEXT, enforcement TEXT,
|
|
70
|
+
rationale TEXT, source_decision TEXT, violations TEXT,
|
|
71
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
-- Unified full-text search across every entity. Rebuilt on index; bm25-ranked.
|
|
75
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
|
|
76
|
+
ref UNINDEXED, -- entity id
|
|
77
|
+
kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints
|
|
78
|
+
title,
|
|
79
|
+
body,
|
|
80
|
+
tokenize = 'porter unicode61'
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
-- Local semantic-search vectors (opt-in; written by \`hunch embed\`). One row per
|
|
84
|
+
-- (ref, model); vec is a Float32 BLOB. DELIBERATELY NOT in RESET_SQL: reindex()
|
|
85
|
+
-- runs RESET on nearly every path (MCP startup, every query/context), so resetting
|
|
86
|
+
-- embeddings here would wipe them constantly and make the feature a no-op. Staleness
|
|
87
|
+
-- is tracked by doc_hash and reconciled by pruneStaleEmbeddings() instead. Recall is
|
|
88
|
+
-- exact brute-force cosine in JS (graphs are small); sqlite-vec only past ~100k rows.
|
|
89
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
90
|
+
ref TEXT, kind TEXT, model TEXT, dim INTEGER, doc_hash TEXT, vec BLOB,
|
|
91
|
+
PRIMARY KEY (ref, model)
|
|
92
|
+
);
|
|
93
93
|
`;
|
|
94
94
|
/** Drop derived data (used before a full reindex). NOTE: embeddings is omitted on
|
|
95
95
|
* purpose — see the embeddings table comment above. */
|
|
96
|
-
export const RESET_SQL = /* sql */ `
|
|
97
|
-
DELETE FROM components; DELETE FROM edges; DELETE FROM symbols;
|
|
98
|
-
DELETE FROM decisions; DELETE FROM bugs; DELETE FROM constraints;
|
|
99
|
-
DELETE FROM search;
|
|
96
|
+
export const RESET_SQL = /* sql */ `
|
|
97
|
+
DELETE FROM components; DELETE FROM edges; DELETE FROM symbols;
|
|
98
|
+
DELETE FROM decisions; DELETE FROM bugs; DELETE FROM constraints;
|
|
99
|
+
DELETE FROM search;
|
|
100
100
|
`;
|
|
101
101
|
//# sourceMappingURL=schema.js.map
|
|
@@ -96,10 +96,10 @@ export function pexecIn(cmd, args, opts = {}) {
|
|
|
96
96
|
child.stdin.end();
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
|
-
const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
|
|
100
|
-
developer activity (a git commit diff, or a test failure) into a single structured
|
|
101
|
-
"why" record. Be precise and evidence-grounded; never invent facts not supported by
|
|
102
|
-
the input. Prefer short, concrete statements. If intent is unclear, say so plainly
|
|
99
|
+
const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
|
|
100
|
+
developer activity (a git commit diff, or a test failure) into a single structured
|
|
101
|
+
"why" record. Be precise and evidence-grounded; never invent facts not supported by
|
|
102
|
+
the input. Prefer short, concrete statements. If intent is unclear, say so plainly
|
|
103
103
|
rather than guessing.`;
|
|
104
104
|
const DECISION_TOOL = {
|
|
105
105
|
name: "emit_decision",
|