@davesheffer/hunch 0.24.0 → 0.26.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 CHANGED
@@ -66,6 +66,24 @@ came before. Local-first, no documentation toil, no SaaS.
66
66
  [provenance](https://hunch-pi.vercel.app/docs#provenance) ·
67
67
  [time-travel](https://hunch-pi.vercel.app/docs#time-travel)
68
68
 
69
+ ## Why Hunch is different
70
+
71
+ "Memory for coding agents" is getting crowded, but most of it is a *server-side, ephemeral,
72
+ single-vendor* RAG cache over your current code. Hunch is the opposite on every axis — and that
73
+ combination is the moat:
74
+
75
+ | | Typical agent memory | **Hunch** |
76
+ |---|---|---|
77
+ | **Storage** | server-side / a vendor's cloud | **git-tracked JSON in your repo** — diff it, review it in PRs, sync it over `git push` |
78
+ | **Lifetime** | the session; often auto-expiring | the **lifetime of the codebase** — non-destructive supersede/veto keeps the *why-it-changed* trail |
79
+ | **Clients** | one vendor's agent | **client-agnostic** — one `.hunch/` graph serves Claude Code, Cursor, Copilot & Windsurf via MCP |
80
+ | **What's stored** | opaque extracted "facts" | **structured ADRs** — decisions with rejected-alternatives, bug lineage, and invariants |
81
+ | **Enforcement** | advisory / just-in-time hints | **fail-closed deterministic guards** — no model in the block path; a commit fails on a human-vouched, set-intersection match |
82
+ | **Trust** | take it on faith | **provenance on every record** (source + confidence + evidence) and a measurable retrieval signal (`hunch eval`) |
83
+
84
+ The short version: **git tracks *what* changed; Hunch tracks *why*** — locally, durably, and under
85
+ your control, with guards that actually hold the line instead of just suggesting.
86
+
69
87
  ## Getting started
70
88
 
71
89
  ```bash
package/dist/cli/index.js CHANGED
@@ -28,7 +28,8 @@ import { indexRepo } from "../extractors/indexer.js";
28
28
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
29
29
  import { parseTestReport } from "../extractors/testreport.js";
30
30
  import { selectProvider } from "../synthesis/provider.js";
31
- import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists, commitAndPushHunch, gitUntrackCached } from "../extractors/git.js";
31
+ import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, gitUntrackCached } from "../extractors/git.js";
32
+ import { runbookId } from "../core/ids.js";
32
33
  import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
33
34
  import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
34
35
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
@@ -418,6 +419,7 @@ program
418
419
  .requiredOption("--file <path>", "golden set JSON: [{ query, expected: [refs], note? }]")
419
420
  .option("--k <n>", "top-k cutoff", "10")
420
421
  .option("--semantic", "also blend the semantic stream (requires `hunch embed`; default is deterministic FTS + graph)")
422
+ .option("--kind <kind>", "restrict scoring to one record kind (e.g. runbooks) — scoped retrieval")
421
423
  .action(async (opts) => {
422
424
  const { store } = storeFor();
423
425
  store.reindex(); // reflect any out-of-band JSON edits before scoring
@@ -437,7 +439,7 @@ program
437
439
  // Default is deterministic (FTS + graph, no model). --semantic only adds the
438
440
  // semantic leg when embeddings actually exist; otherwise it's still FTS + graph.
439
441
  const embedder = opts.semantic ? await selectEmbedder() : undefined;
440
- const lift = await evaluateGraphLift(store, cases, { k, embedder });
442
+ const lift = await evaluateGraphLift(store, cases, { k, embedder, kind: opts.kind });
441
443
  const pct = (x) => `${(x * 100).toFixed(1)}%`;
442
444
  const dpt = (x) => `${x >= 0 ? "+" : ""}${(x * 100).toFixed(1)}pt`;
443
445
  const dnum = (x) => `${x >= 0 ? "+" : ""}${x.toFixed(3)}`;
@@ -454,6 +456,77 @@ program
454
456
  }
455
457
  store.close();
456
458
  });
459
+ // ---- runbook (distill reusable "how" from a commit range; roadmap #5) ------
460
+ program
461
+ .command("runbook")
462
+ .description("Capture a runbook (the 'how' of a recurring task) from a commit range, or --find one. Advisory.")
463
+ .argument("[range]", "commit range for capture: <base>..<head>, or <base> (→ <base>..HEAD)")
464
+ .option("--task <task>", "the recurring task this runbook answers (capture mode)")
465
+ .option("--find <query>", "look up the runbooks that best match a task/intent (scoped retrieval)")
466
+ .option("--semantic", "use semantic retrieval for --find (requires `hunch embed`)")
467
+ .option("--private", "capture into the private overlay (HUNCH_PRIVATE_DIR), not the committed repo")
468
+ .action(async (range, opts) => {
469
+ const { store, root } = storeFor();
470
+ // Lookup mode: scoped runbook retrieval (search within runbooks, not the whole graph).
471
+ if (opts.find) {
472
+ const emb = opts.semantic ? await selectEmbedder() : undefined;
473
+ const hits = await store.searchRunbooks(opts.find, 5, { embedder: emb });
474
+ if (!hits.length)
475
+ console.log(`No runbook matches "${opts.find}".`);
476
+ else {
477
+ console.log(`Runbooks for "${opts.find}":\n`);
478
+ for (const h of hits)
479
+ console.log(`• ${h.ref} — ${h.title}\n ${h.snippet}`);
480
+ }
481
+ store.close();
482
+ return;
483
+ }
484
+ // Capture mode.
485
+ if (!range || !opts.task) {
486
+ store.close();
487
+ return fail("capture needs a <range> and --task (or use --find <query> to look up)");
488
+ }
489
+ if (opts.private && !store.hasPrivate) {
490
+ store.close();
491
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
492
+ }
493
+ const [base, head = "HEAD"] = range.split("..");
494
+ if (!base || !revExists(base, root)) {
495
+ store.close();
496
+ return fail(`base ref "${base ?? ""}" not found (range: ${range})`);
497
+ }
498
+ const steps = rangeSubjects(base, root, head);
499
+ const files = rangeFiles(base, root, head);
500
+ if (!steps.length && !files.length) {
501
+ store.close();
502
+ return fail(`no commits or changes in range ${range}`);
503
+ }
504
+ const now = new Date().toISOString();
505
+ const rec = {
506
+ id: runbookId(opts.task),
507
+ task: opts.task,
508
+ trigger: [opts.task],
509
+ steps, // already oldest-first (chronological procedure)
510
+ files,
511
+ gotchas: [],
512
+ outcome: "",
513
+ source_range: range,
514
+ valid_from: now,
515
+ valid_to: null,
516
+ // Deterministic draft (commit subjects + changed files); advisory, low-confidence.
517
+ // Refine steps/gotchas by hand. LLM enrichment is a later tier.
518
+ provenance: { source: "extracted", confidence: 0.5, evidence: [range] },
519
+ date: now,
520
+ };
521
+ if (opts.private)
522
+ store.putPrivate("runbooks", rec);
523
+ else
524
+ store.json.put("runbooks", rec);
525
+ store.reindex();
526
+ console.log(`✓ runbook ${rec.id} — "${rec.task}" (${rec.steps.length} steps, ${rec.files.length} files)${opts.private ? " [private overlay]" : ""}`);
527
+ console.log(dim(" advisory, deterministic draft — refine the steps/gotchas; surfaced via `hunch query` and MCP."));
528
+ store.close();
529
+ });
457
530
  // ---- embed (opt-in semantic search) ---------------------------------------
458
531
  program
459
532
  .command("embed")
package/dist/core/ids.js CHANGED
@@ -32,6 +32,11 @@ export function decisionId(seed) {
32
32
  export function bugId(seed) {
33
33
  return "bug_" + shortHash(seed);
34
34
  }
35
+ /** Runbook id seeded by its task; trim + lowercase so re-capturing the same task
36
+ * is idempotent rather than minting a duplicate. */
37
+ export function runbookId(seed) {
38
+ return "rb_" + shortHash(seed.trim().toLowerCase());
39
+ }
35
40
  /** Constraint id seeded by its statement. Trim + lowercase so trivial
36
41
  * whitespace/case variants of the same rule collapse to one id (idempotent
37
42
  * re-capture), instead of minting a duplicate constraint. */
@@ -163,8 +163,25 @@ export const ConstraintSchema = z.object({
163
163
  valid_to: z.string().nullable().default(null).describe("ISO instant it was retired (null = active)"),
164
164
  provenance: ProvenanceSchema,
165
165
  });
166
- /** The six entity collections, keyed by their on-disk directory name. */
167
- export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints"];
166
+ /** A reusable "how" for a recurring task trajectory/runbook memory (roadmap #5).
167
+ * ADVISORY retrieval context only; never enters any block path. Distilled from a
168
+ * commit range, surfaced through the same FTS+graph retrieval as every record. */
169
+ export const RunbookSchema = z.object({
170
+ id: z.string().describe("rb_*"),
171
+ task: z.string().describe("the recurring task this answers"),
172
+ trigger: z.array(z.string()).default([]).describe("phrases/intents that should surface it"),
173
+ steps: z.array(z.string()).default([]).describe("ordered procedure"),
174
+ files: z.array(z.string()).default([]).describe("canonical files the task touches (drift-checkable)"),
175
+ gotchas: z.array(z.string()).default([]),
176
+ outcome: z.string().default("").describe("what 'done' looks like"),
177
+ source_range: z.string().nullable().default(null).describe("the commit range it was distilled from"),
178
+ valid_from: z.string().optional(),
179
+ valid_to: z.string().nullable().default(null),
180
+ provenance: ProvenanceSchema,
181
+ date: z.string(),
182
+ });
183
+ /** The entity collections, keyed by their on-disk directory name. */
184
+ export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks"];
168
185
  export const SCHEMAS = {
169
186
  components: ComponentSchema,
170
187
  edges: EdgeSchema,
@@ -172,6 +189,7 @@ export const SCHEMAS = {
172
189
  decisions: DecisionSchema,
173
190
  bugs: BugSchema,
174
191
  constraints: ConstraintSchema,
192
+ runbooks: RunbookSchema,
175
193
  };
176
194
  /** Default provenance helper for deterministic (extracted) records. */
177
195
  export function extracted(confidence, evidence = []) {
@@ -3,7 +3,12 @@ export async function evaluateRetrieval(store, cases, opts = {}) {
3
3
  const k = opts.k ?? 10;
4
4
  const perCase = [];
5
5
  for (const c of cases) {
6
- const hits = await store.hybridSearch(c.query, k, { embedder: opts.embedder, graphWeight: opts.graphWeight });
6
+ // A kind-scoped eval uses true scoped retrieval (candidate pool restricted to the
7
+ // kind from the start), not a whole-corpus fetch + filter — the latter's top-50 cap
8
+ // buries terse records before any filter.
9
+ const hits = opts.kind
10
+ ? await store.searchScoped(c.query, opts.kind, k, { embedder: opts.embedder })
11
+ : await store.hybridSearch(c.query, k, { embedder: opts.embedder, graphWeight: opts.graphWeight });
7
12
  const top = hits.slice(0, k).map((h) => h.ref);
8
13
  const expected = new Set(c.expected);
9
14
  let found = 0;
@@ -194,6 +194,12 @@ export function rangeFiles(base, cwd, head = "HEAD") {
194
194
  const out = gitSafe(["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
195
195
  return out ? out.split("\n").filter(Boolean) : [];
196
196
  }
197
+ /** Commit subjects on `head` since `base` (2-dot: commits added by the task),
198
+ * oldest-first, for distilling a runbook's ordered steps (roadmap #5). */
199
+ export function rangeSubjects(base, cwd, head = "HEAD", max = 50) {
200
+ const out = gitSafe(["log", "--reverse", `-n${max}`, "--format=%s", `${base}..${head}`], cwd);
201
+ return out ? out.split("\n").filter(Boolean) : [];
202
+ }
197
203
  /** The PR's unified diff vs `base` (3-dot), for the Regression Guard's structural
198
204
  * analysis. Same noise-exclusion + truncation budget as commit/staged diffs. */
199
205
  export function rangeDiff(base, cwd, head = "HEAD", maxBytes = 60_000) {
@@ -77,6 +77,25 @@ export function buildServer(root) {
77
77
  });
78
78
  return ok(`Top matches for "${query}":\n\n${lines.join("\n")}`);
79
79
  });
80
+ // -- hunch_runbook --------------------------------------------------------
81
+ server.registerTool("hunch_runbook", {
82
+ title: "Find a runbook for a task",
83
+ description: "Look up the proven 'how-to' (ordered steps + files) for a recurring task — runbook-SCOPED retrieval (searches within runbooks, not the whole graph). Use at the START of a task to reuse a known procedure instead of re-deriving it. Advisory.",
84
+ inputSchema: { task: z.string().describe("The task/intent, e.g. 'add an MCP tool' or 'cut a release'.") },
85
+ }, async ({ task }) => {
86
+ const hits = await store.searchRunbooks(task, 5, { embedder: await embedderReady });
87
+ if (!hits.length)
88
+ return ok(`No runbook for "${task}" yet. Capture one with: hunch runbook <base>..<head> --task "${task}"`);
89
+ const lines = hits.map((h) => {
90
+ const r = store.resolve(h.ref)?.record;
91
+ if (!r)
92
+ return `• ${h.ref} — ${h.title}`;
93
+ const steps = r.steps.length ? `\n steps: ${r.steps.map((s, i) => `${i + 1}. ${s}`).join(" ")}` : "";
94
+ const files = r.files.length ? `\n files: ${r.files.slice(0, 8).join(", ")}` : "";
95
+ return `• ${r.id} — ${r.task}${steps}${files}${provLine(r)}`;
96
+ });
97
+ return ok(`Runbooks for "${task}" (advisory — a proven 'how', refine to fit):\n\n${lines.join("\n\n")}`);
98
+ });
80
99
  // -- hunch_why ------------------------------------------------------------
81
100
  server.registerTool("hunch_why", {
82
101
  title: "Explain why a file/symbol is the way it is",
@@ -179,6 +179,13 @@ export class HunchStore {
179
179
  fts(c.id, "constraints", c.statement, `${c.rationale} ${c.scope.join(" ")}`);
180
180
  }
181
181
  counts.constraints = cons.length;
182
+ // Runbooks (roadmap #5): advisory "how" records — no dedicated SQL table, they
183
+ // ride the unified `search` FTS so they retrieve through the same FTS+graph path.
184
+ const runbooks = this.recs("runbooks");
185
+ for (const r of runbooks) {
186
+ fts(r.id, "runbooks", r.task, `${r.trigger.join(" ")} ${r.steps.join(" ")} ${r.gotchas.join(" ")} ${r.outcome} ${r.files.join(" ")}`);
187
+ }
188
+ counts.runbooks = runbooks.length;
182
189
  void j;
183
190
  });
184
191
  tx();
@@ -326,14 +333,60 @@ export class HunchStore {
326
333
  return fts.slice(0, limit);
327
334
  return this.rrfFuse(fts, sem, graph, limit, gw);
328
335
  }
336
+ /** Runbook-scoped retrieval (roadmap #5): the same FTS+graph(+semantic) fusion,
337
+ * restricted to the `runbooks` kind — so a "what's the procedure for X" query
338
+ * competes only with other runbooks, not the whole graph. Measurement showed
339
+ * whole-corpus retrieval buries terse runbooks (33% recall); scoping + semantic
340
+ * lifted recall@5 to 83% (dec_1239efae54 follow-up). Pass an embedder for the
341
+ * semantic leg; omit for keyword+graph. */
342
+ async searchRunbooks(query, limit = 5, opts = {}) {
343
+ return this.searchScoped(query, "runbooks", limit, opts);
344
+ }
345
+ /** Kind-SCOPED retrieval: FTS + (optional) semantic fused, but the candidate pool is
346
+ * restricted to one record kind from the START — not over-fetched from a whole-corpus
347
+ * ranking (whose top-50 cap can bury a terse record before any filter). This is what
348
+ * lifted runbook recall@5 from 33% → 83% in the measurement (dec_1239efae54 follow-up). */
349
+ async searchScoped(query, kind, limit = 5, opts = {}) {
350
+ const fts = this.scopedFts(query, kind, Math.max(limit, 20));
351
+ const embedder = opts.embedder !== undefined ? opts.embedder : await selectEmbedder();
352
+ let sem = [];
353
+ if (embedder && this.semanticReady(embedder)) {
354
+ try {
355
+ const [qvec] = await embedder.embed([query]);
356
+ if (qvec)
357
+ sem = this.cosineRank(qvec, embedder.id, Math.max(limit, 20), kind);
358
+ }
359
+ catch {
360
+ sem = [];
361
+ }
362
+ }
363
+ if (!sem.length)
364
+ return fts.slice(0, limit);
365
+ return this.rrfFuse(fts, sem, [], limit);
366
+ }
367
+ /** FTS bm25 over a single kind (the `kind` column is UNINDEXED, so a plain `=`
368
+ * constraint composes with MATCH). Empty when the query has no FTS-able terms. */
369
+ scopedFts(query, kind, limit) {
370
+ const match = toFtsQuery(query);
371
+ if (!match)
372
+ return [];
373
+ try {
374
+ const rows = this.db.prepare(`SELECT ref, kind, title, snippet(search, 3, '[', ']', '…', 12) AS snip, bm25(search) AS score
375
+ FROM search WHERE search MATCH ? AND kind = ? ORDER BY score LIMIT ?`).all(match, kind, limit);
376
+ return rows.map((r) => ({ ref: r.ref, kind: r.kind, title: r.title, snippet: r.snip, score: r.score }));
377
+ }
378
+ catch {
379
+ return [];
380
+ }
381
+ }
329
382
  /** Brute-force exact cosine top-n over stored vectors for one model. Vectors are
330
383
  * pre-normalized, so cosine == dot product. Scoped to `dim = qvec.length` so a
331
384
  * row stored at a different dimension (model id reused at a new dim) can never
332
385
  * drive an out-of-bounds BLOB read; any with an unexpected byte length are
333
386
  * skipped defensively rather than crashing the query. */
334
- cosineRank(qvec, model, n) {
387
+ cosineRank(qvec, model, n, kind) {
335
388
  const dim = qvec.length;
336
- const rows = this.db.prepare(`SELECT ref, kind, vec FROM embeddings WHERE model = ? AND dim = ?`).all(model, dim);
389
+ const rows = this.db.prepare(`SELECT ref, kind, vec FROM embeddings WHERE model = ? AND dim = ?${kind ? " AND kind = ?" : ""}`).all(...(kind ? [model, dim, kind] : [model, dim]));
337
390
  const scored = [];
338
391
  for (const r of rows) {
339
392
  if (r.vec.byteLength !== dim * 4)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",