@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 +18 -0
- package/dist/cli/index.js +75 -2
- package/dist/core/ids.js +5 -0
- package/dist/core/types.js +20 -2
- package/dist/eval/harness.js +6 -1
- package/dist/extractors/git.js +6 -0
- package/dist/mcp/server.js +19 -0
- package/dist/store/hunchStore.js +55 -2
- package/package.json +1 -1
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. */
|
package/dist/core/types.js
CHANGED
|
@@ -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
|
-
/**
|
|
167
|
-
|
|
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 = []) {
|
package/dist/eval/harness.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/extractors/git.js
CHANGED
|
@@ -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) {
|
package/dist/mcp/server.js
CHANGED
|
@@ -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",
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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.",
|