@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/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 Claude Code \"why is X built this way?\"");
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
- emitContext("UserPromptSubmit", HOOK_REMINDER);
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")
@@ -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
- // Use the absolute node binary (process.execPath) rather than a bare `node`,
17
- // so the hook works even when nvm's `node` isn't on the hook's PATH.
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
@@ -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
@@ -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) {
@@ -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();