@davesheffer/hunch 0.12.1 → 0.13.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/README.md +404 -372
- package/dist/cli/index.js +10 -5
- package/dist/cli/invocation.js +19 -2
- package/dist/core/correction.js +79 -0
- package/dist/core/ids.js +4 -2
- package/dist/core/paths.js +8 -0
- package/dist/integrations/scaffold.js +30 -30
- package/dist/mcp/server.js +36 -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 +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -17,7 +17,8 @@ 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
|
+
import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
|
|
21
22
|
import { HunchStore } from "../store/hunchStore.js";
|
|
22
23
|
import { selectEmbedder } from "../store/embedder.js";
|
|
23
24
|
import { indexRepo } from "../extractors/indexer.js";
|
|
@@ -55,7 +56,7 @@ function storeFor() {
|
|
|
55
56
|
// ---- init -----------------------------------------------------------------
|
|
56
57
|
program
|
|
57
58
|
.command("init")
|
|
58
|
-
.description("Scaffold .hunch/, index the repo, install the git hook, and wire up Claude Code.")
|
|
59
|
+
.description("Scaffold .hunch/, index the repo, install the git hook, and wire up your coding assistants (Claude Code, Cursor, VS Code, Windsurf, Codex).")
|
|
59
60
|
.option("--no-index", "skip the initial repo index")
|
|
60
61
|
.option("--no-enforce", "do not install the advisory pre-commit constraint guard")
|
|
61
62
|
.option("--enforce-strict", "make the pre-commit guard FAIL the commit on a direct, high-confidence, non-stale blocking invariant")
|
|
@@ -133,7 +134,7 @@ program
|
|
|
133
134
|
console.log(` ⚠ skipped ${p.assistant}: ${p.error}`);
|
|
134
135
|
}
|
|
135
136
|
store.close();
|
|
136
|
-
console.log("\nNext: make a commit (the hook captures a decision), then ask
|
|
137
|
+
console.log("\nNext: make a commit (the hook captures a decision), then ask your coding assistant \"why is X built this way?\"");
|
|
137
138
|
console.log("Cold start? Seed from history: hunch backfill --since 90d");
|
|
138
139
|
});
|
|
139
140
|
// ---- index ----------------------------------------------------------------
|
|
@@ -409,7 +410,7 @@ program
|
|
|
409
410
|
return fail(`--severity must be one of: ${SEV.join(", ")}`);
|
|
410
411
|
const { store, root } = storeFor();
|
|
411
412
|
store.json.ensureDirs();
|
|
412
|
-
const scope = opts.scope.split(",").map((s) => s.trim()).filter(Boolean);
|
|
413
|
+
const scope = opts.scope.split(",").map((s) => toPosixTarget(s.trim())).filter(Boolean);
|
|
413
414
|
const c = store.json.put("constraints", {
|
|
414
415
|
id: constraintId(statement),
|
|
415
416
|
type: opts.type,
|
|
@@ -736,7 +737,11 @@ program
|
|
|
736
737
|
if (firmness === "off")
|
|
737
738
|
return;
|
|
738
739
|
if (evt.hook_event_name === "UserPromptSubmit") {
|
|
739
|
-
|
|
740
|
+
// When the prompt reads like a correction ("no / that's wrong / never X"),
|
|
741
|
+
// nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
|
|
742
|
+
// not just obey it this once and forget it next session.
|
|
743
|
+
const text = looksLikeCorrection(evt.prompt) ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
|
|
744
|
+
emitContext("UserPromptSubmit", text);
|
|
740
745
|
return;
|
|
741
746
|
}
|
|
742
747
|
if (evt.hook_event_name !== "PreToolUse")
|
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] },
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** "Never Twice" — turn a human correction of the agent into a first-class,
|
|
2
|
+
* enforced Constraint (DESIGN: Correction Capture → Enforced Constraint).
|
|
3
|
+
*
|
|
4
|
+
* Two pure, client-agnostic pieces, factored out of the MCP server and the
|
|
5
|
+
* agent hook so they are unit-testable without spinning either up:
|
|
6
|
+
* - looksLikeCorrection(): does a user prompt read like "no / that's wrong /
|
|
7
|
+
* never do X" — the cue to nudge the agent to persist it.
|
|
8
|
+
* - buildCorrectionConstraint(): mint the Constraint record (human-confirmed,
|
|
9
|
+
* scoped conservatively) that the pre-edit hook + CI guard then enforce.
|
|
10
|
+
*/
|
|
11
|
+
import { constraintId } from "./ids.js";
|
|
12
|
+
import { toPosixTarget } from "./paths.js";
|
|
13
|
+
/** Correction cues. Deliberately conservative — anchored to imperative/rebuke
|
|
14
|
+
* phrasing, not bare "no", so ordinary conversational negation ("no idea",
|
|
15
|
+
* "no problem") doesn't train users to ignore the nudge (research risk #5). */
|
|
16
|
+
const CORRECTION_PATTERNS = [
|
|
17
|
+
// OPENS with a rebuke — but exclude benign "no problem / no idea / no test exists…".
|
|
18
|
+
// The exclusion list guards against stateful conversational negation ("no tests pass",
|
|
19
|
+
// "no way to fix this") firing the nudge and training users to ignore it.
|
|
20
|
+
/^\s*no\b(?!\s+(problem|worries|idea|rush|need|biggie|thanks|thank|prob|clue|luck|difference|harm|reason|point|test|tests|way|ways|chance|context|functions?|method|file|files|change|changes|diff|other|more))/i,
|
|
21
|
+
/^\s*(nope|stop)\b/i,
|
|
22
|
+
/\b(that'?s|that is|this is) (wrong|incorrect|not right|not what)\b/i,
|
|
23
|
+
/\b(never|do not ever|don'?t ever) (do|use|call|add|write|put|import|commit|touch)\b/i,
|
|
24
|
+
/\b(don'?t|do not) (do|use|call|add|write|put|commit) (that|this|it)\b/i,
|
|
25
|
+
/\b(you must|must always|you should always|make sure (to|you|that you))\b/i,
|
|
26
|
+
/\b(i (already )?told you|i said|as i said|like i said)\b/i,
|
|
27
|
+
/\b(undo|revert) (that|this|it|your)\b/i,
|
|
28
|
+
/\b(not like that|don'?t do (that|this)( again)?|stop doing (that|this))\b/i,
|
|
29
|
+
];
|
|
30
|
+
export function looksLikeCorrection(prompt) {
|
|
31
|
+
if (!prompt || typeof prompt !== "string")
|
|
32
|
+
return false;
|
|
33
|
+
return CORRECTION_PATTERNS.some((re) => re.test(prompt));
|
|
34
|
+
}
|
|
35
|
+
/** One-line nudge appended to the UserPromptSubmit hook context when a prompt
|
|
36
|
+
* reads like a correction — surfaces the write tool so the rule gets ENFORCED,
|
|
37
|
+
* not merely remembered. Client-agnostic (no Claude-only wording). */
|
|
38
|
+
export const CORRECTION_NUDGE = "This looks like a correction. If it's a rule the agent should never break again, " +
|
|
39
|
+
"call hunch_record_correction({ rule, scope_hint_file, severity, applies_to_all }) so it " +
|
|
40
|
+
"becomes an enforced, scoped constraint (held at edit-time and in CI) — not a one-off the next session forgets. " +
|
|
41
|
+
"Use severity:\"blocking\" only when the human said never/must; set applies_to_all:true only if the rule is genuinely repo-wide.";
|
|
42
|
+
/**
|
|
43
|
+
* Build the Constraint a correction mints. Pure (caller passes `now`), so the
|
|
44
|
+
* scope/severity policy is testable in isolation. Key safety rule (research
|
|
45
|
+
* risk #2 — the scope footgun): a repo-wide ("**") constraint may only be
|
|
46
|
+
* BLOCKING when the caller explicitly set applies_to_all; otherwise a single
|
|
47
|
+
* mis-scoped correction would deny every edit under strict firmness, so we
|
|
48
|
+
* down-rank it to a warning.
|
|
49
|
+
*/
|
|
50
|
+
export function buildCorrectionConstraint(input, now) {
|
|
51
|
+
const rule = input.rule.trim();
|
|
52
|
+
if (!rule)
|
|
53
|
+
throw new Error("rule must not be empty");
|
|
54
|
+
// A blank/"." scope hint would mint a meaningless or repo-wide constraint by
|
|
55
|
+
// accident, so fall back to "**" (which the severity guard below then keeps
|
|
56
|
+
// non-blocking unless applies_to_all was explicitly set).
|
|
57
|
+
const hinted = input.scope_hint_file ? toPosixTarget(input.scope_hint_file) : "";
|
|
58
|
+
const scope = input.applies_to_all || !hinted || hinted === "." ? ["**"] : [hinted];
|
|
59
|
+
const repoWide = scope.length === 1 && scope[0] === "**";
|
|
60
|
+
let severity = input.severity ?? "warning";
|
|
61
|
+
if (severity === "blocking" && repoWide && !input.applies_to_all)
|
|
62
|
+
severity = "warning";
|
|
63
|
+
return {
|
|
64
|
+
id: constraintId(rule),
|
|
65
|
+
type: input.type ?? "correctness",
|
|
66
|
+
statement: rule,
|
|
67
|
+
scope,
|
|
68
|
+
severity,
|
|
69
|
+
enforcement: "advisory_v1",
|
|
70
|
+
rationale: input.rationale ?? "Captured from a human correction of the agent (Never Twice).",
|
|
71
|
+
source_decision: input.source_decision ?? null,
|
|
72
|
+
violations: [],
|
|
73
|
+
status: "active",
|
|
74
|
+
valid_from: now,
|
|
75
|
+
valid_to: null,
|
|
76
|
+
provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: now },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=correction.js.map
|
package/dist/core/ids.js
CHANGED
|
@@ -32,8 +32,10 @@ export function decisionId(seed) {
|
|
|
32
32
|
export function bugId(seed) {
|
|
33
33
|
return "bug_" + shortHash(seed);
|
|
34
34
|
}
|
|
35
|
-
/** Constraint id seeded by its statement.
|
|
35
|
+
/** Constraint id seeded by its statement. Trim + lowercase so trivial
|
|
36
|
+
* whitespace/case variants of the same rule collapse to one id (idempotent
|
|
37
|
+
* re-capture), instead of minting a duplicate constraint. */
|
|
36
38
|
export function constraintId(statement) {
|
|
37
|
-
return "con_" + shortHash(statement.toLowerCase());
|
|
39
|
+
return "con_" + shortHash(statement.trim().toLowerCase());
|
|
38
40
|
}
|
|
39
41
|
//# sourceMappingURL=ids.js.map
|
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 {
|
|
@@ -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,10 +9,11 @@
|
|
|
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";
|
|
16
|
+
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
16
17
|
import { revParse, asOfDate } from "../extractors/git.js";
|
|
17
18
|
import { formatContext } from "../core/format.js";
|
|
18
19
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
@@ -29,6 +30,7 @@ const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
|
29
30
|
const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
|
|
30
31
|
/** Resolve a free-form target (symbol id / name / file path) to symbol records. */
|
|
31
32
|
function resolveSymbols(store, target) {
|
|
33
|
+
target = toPosixTarget(target);
|
|
32
34
|
const syms = store.json.loadAll("symbols");
|
|
33
35
|
const byId = syms.find((s) => s.id === target);
|
|
34
36
|
if (byId)
|
|
@@ -42,7 +44,7 @@ function resolveSymbols(store, target) {
|
|
|
42
44
|
* radius). Falls back to the literal target so direct-scope checks still run. */
|
|
43
45
|
function resolveFiles(store, target) {
|
|
44
46
|
const files = new Set(resolveSymbols(store, target).map((s) => s.file));
|
|
45
|
-
return files.size ? [...files] : [target];
|
|
47
|
+
return files.size ? [...files] : [toPosixTarget(target)];
|
|
46
48
|
}
|
|
47
49
|
export function buildServer(root) {
|
|
48
50
|
const store = new HunchStore(hunchPaths(root));
|
|
@@ -261,7 +263,7 @@ export function buildServer(root) {
|
|
|
261
263
|
consequences: decision.consequences ?? [],
|
|
262
264
|
alternatives_rejected: decision.alternatives_rejected ?? [],
|
|
263
265
|
related_components: decision.related_components ?? existing?.related_components ?? [],
|
|
264
|
-
related_files: decision.related_files ?? existing?.related_files ?? [],
|
|
266
|
+
related_files: (decision.related_files ?? existing?.related_files ?? []).map(toPosixTarget),
|
|
265
267
|
supersedes: decision.supersedes ?? existing?.supersedes ?? null,
|
|
266
268
|
superseded_by: existing?.superseded_by ?? null,
|
|
267
269
|
caused_by_bug: existing?.caused_by_bug ?? null,
|
|
@@ -269,7 +271,7 @@ export function buildServer(root) {
|
|
|
269
271
|
valid_from: existing?.valid_from ?? now,
|
|
270
272
|
valid_to: existing?.valid_to ?? null,
|
|
271
273
|
retired: existing?.retired ?? { symbols: [], deps: [] },
|
|
272
|
-
provenance: { source, confidence: 0.95, evidence: decision.related_files ?? existing?.provenance.evidence ?? [] },
|
|
274
|
+
provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
|
|
273
275
|
date: now,
|
|
274
276
|
};
|
|
275
277
|
store.json.put("decisions", rec);
|
|
@@ -285,6 +287,36 @@ export function buildServer(root) {
|
|
|
285
287
|
return err(`Failed to record decision: ${e.message}`);
|
|
286
288
|
}
|
|
287
289
|
});
|
|
290
|
+
// -- hunch_record_correction (write-back: "Never Twice") ------------------
|
|
291
|
+
server.registerTool("hunch_record_correction", {
|
|
292
|
+
title: "Capture a correction as an enforced constraint (Never Twice)",
|
|
293
|
+
description: "When a human corrects the agent ('no, do it this way' / 'never call X here'), persist that correction as a first-class, SCOPED Constraint with provenance — so the pre-edit hook and the CI Constraint Guard hold EVERY assistant to it from now on, instead of it being forgotten next session. Writes to the shared .hunch/ graph (client-agnostic). Set severity:'blocking' only when the human said never/must; set applies_to_all:true only when the rule is genuinely repo-wide (otherwise it is scoped to scope_hint_file).",
|
|
294
|
+
inputSchema: {
|
|
295
|
+
rule: z.string().describe("The invariant in the human's words, e.g. \"never call the pay-per-token API here\"."),
|
|
296
|
+
scope_hint_file: z.string().optional().describe("A file the correction was about; scopes the constraint to it (the conservative default)."),
|
|
297
|
+
severity: z.enum(["advisory", "warning", "blocking"]).optional().describe("Default 'warning'. Use 'blocking' only for a hard never/must rule."),
|
|
298
|
+
applies_to_all: z.boolean().optional().describe("True ONLY if the rule is genuinely repo-wide (scopes to **); required to make a repo-wide rule blocking."),
|
|
299
|
+
type: z.enum(["security", "performance", "correctness", "architecture", "compliance"]).optional(),
|
|
300
|
+
rationale: z.string().optional().describe("Why it must hold."),
|
|
301
|
+
source_decision: z.string().optional().describe("id of a decision this correction derives from."),
|
|
302
|
+
},
|
|
303
|
+
}, async (input) => {
|
|
304
|
+
try {
|
|
305
|
+
if (!input.rule || !input.rule.trim())
|
|
306
|
+
return err("rule is required — state the invariant in plain words.");
|
|
307
|
+
const rec = buildCorrectionConstraint(input, new Date().toISOString());
|
|
308
|
+
const existing = store.json.get("constraints", rec.id);
|
|
309
|
+
store.json.put("constraints", rec);
|
|
310
|
+
store.reindex();
|
|
311
|
+
const enforce = rec.severity === "blocking"
|
|
312
|
+
? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
|
|
313
|
+
: "flags violating edits and PRs (advisory)";
|
|
314
|
+
return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}). It now ${enforce}.`);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
return err(`Failed to record correction: ${e.message}`);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
288
320
|
return server;
|
|
289
321
|
}
|
|
290
322
|
function provLine(record) {
|
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();
|