@davesheffer/hunch 0.1.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.
@@ -0,0 +1,312 @@
1
+ import { commitMeta, commitDiff, headSha } from "../extractors/git.js";
2
+ import { analyzeDiff } from "../extractors/diff.js";
3
+ import { selectProvider, DeterministicProvider } from "./provider.js";
4
+ import { decisionId, bugId, constraintId } from "../core/ids.js";
5
+ import { pathMatchesGlob } from "../core/glob.js";
6
+ const CODE_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
7
+ const SKIP_SUBJECT = /^(merge|revert|bump|chore\(deps\)|format|lint|wip)\b/i;
8
+ // Below this many changed code lines, a commit with no structural change and no
9
+ // explanatory body isn't worth a paid LLM call. Tunable via HUNCH_SIG_MIN_LINES.
10
+ const SIG_MIN_LINES = Number(process.env.HUNCH_SIG_MIN_LINES) || 12;
11
+ const SIG_MIN_BODY = 40;
12
+ /** Is a commit substantive enough to spend a paid LLM synthesis call on? Pure and
13
+ * deterministic. Any structural change (symbol/dependency delta), non-trivial
14
+ * churn, several files, OR an explanatory commit body signals a real decision
15
+ * worth the model. Everything below (typo/tweak/one-liner with no message) falls
16
+ * to the free deterministic draft — shallower but honestly low-confidence. */
17
+ export function isSignificant(meta, a, codeFiles) {
18
+ const structural = a.addedSymbols.length + a.removedSymbols.length + a.changedSymbols.length + a.addedDeps.length + a.removedDeps.length;
19
+ if (structural > 0)
20
+ return true;
21
+ if (a.addedLines + a.removedLines >= SIG_MIN_LINES)
22
+ return true;
23
+ if (codeFiles.length >= 3)
24
+ return true;
25
+ if (meta.body.trim().length >= SIG_MIN_BODY)
26
+ return true;
27
+ return false;
28
+ }
29
+ /** Capture a Decision from a commit. Defaults to HEAD. Pass `{ force: true }` to
30
+ * re-synthesize a commit that already has an (auto-drafted) decision. */
31
+ export async function syncCommit(store, root, sha, opts = {}) {
32
+ const target = sha || headSha(root);
33
+ if (!target)
34
+ return { status: "skipped", reason: "no HEAD commit" };
35
+ const meta = commitMeta(target, root);
36
+ if (!meta)
37
+ return { status: "skipped", reason: "commit not found" };
38
+ if (SKIP_SUBJECT.test(meta.subject))
39
+ return { status: "skipped", reason: `trivial subject: ${meta.subject}` };
40
+ const codeFiles = meta.files.filter((f) => CODE_RE.test(f));
41
+ if (codeFiles.length === 0)
42
+ return { status: "skipped", reason: "no code files changed" };
43
+ // Seed the id from the COMMIT (stable across runs), not the LLM-generated title
44
+ // (which varies) — so re-syncing a commit updates rather than dupes.
45
+ const id = decisionId(meta.sha);
46
+ const existing = store.json.get("decisions", id);
47
+ // Never clobber a human-confirmed decision with a low-confidence auto-draft —
48
+ // even under --force. Skip BEFORE synthesizing so we never pay for a draft we'd
49
+ // throw away (the old order drafted first, then discarded it here).
50
+ if (existing && existing.provenance.source.includes("human_confirmed")) {
51
+ return { status: "skipped", reason: "human-confirmed decision exists for this commit", decision: existing };
52
+ }
53
+ // Token-thrift idempotency: a decision is already captured for this commit, so
54
+ // don't re-pay the LLM on a re-run (hook double-fire, overlapping backfill, or
55
+ // a manual replay). Re-synthesize only when explicitly forced.
56
+ if (existing && !opts.force) {
57
+ return {
58
+ status: "skipped",
59
+ reason: "decision already captured for this commit (use --force to re-synthesize)",
60
+ decision: existing,
61
+ };
62
+ }
63
+ const diff = commitDiff(target, root);
64
+ const analysis = analyzeDiff(diff);
65
+ // Significance gate: reserve the paid LLM for substantive commits; trivial ones
66
+ // get the FREE deterministic draft (honestly labeled "inferred"/low-confidence,
67
+ // so the Hunch stays accurate-by-provenance). --force always uses the provider.
68
+ const provider = opts.force || isSignificant(meta, analysis, codeFiles)
69
+ ? await selectProvider()
70
+ : new DeterministicProvider();
71
+ const input = { subject: meta.subject, body: meta.body, files: codeFiles, diff, analysis };
72
+ const draft = await draftDecisionSafe(provider, input);
73
+ const components = store.json.loadAll("components");
74
+ const relatedComponents = components
75
+ .filter((c) => codeFiles.some((f) => c.paths.some((g) => pathMatchesGlob(f, g))))
76
+ .map((c) => c.id);
77
+ // Surface any do-not-break constraints this commit's files touch (DESIGN §4
78
+ // "constraint touched" flag) right in the decision context, with evidence.
79
+ const touchedConstraints = store.json
80
+ .loadAll("constraints")
81
+ .filter((c) => codeFiles.some((f) => c.scope.some((g) => pathMatchesGlob(f, g))));
82
+ const constraintNote = touchedConstraints.length
83
+ ? ` Touches invariant(s): ${touchedConstraints.map((c) => `${c.id} (${c.statement})`).join("; ")}.`
84
+ : "";
85
+ const decision = {
86
+ id,
87
+ title: draft.title,
88
+ status: existing?.status === "accepted" ? "accepted" : "proposed",
89
+ context: draft.context + constraintNote,
90
+ decision: draft.decision,
91
+ consequences: draft.consequences,
92
+ alternatives_rejected: draft.alternatives_rejected,
93
+ related_components: relatedComponents,
94
+ related_files: codeFiles,
95
+ supersedes: existing?.supersedes ?? null,
96
+ caused_by_bug: existing?.caused_by_bug ?? null,
97
+ commit: meta.shortSha,
98
+ provenance: {
99
+ source: draft.source,
100
+ confidence: draft.confidence,
101
+ evidence: [`commit:${meta.shortSha}`, ...codeFiles.slice(0, 8)],
102
+ last_verified: new Date().toISOString(), // when the Hunch last re-derived this
103
+ },
104
+ date: meta.date, // the commit date
105
+ };
106
+ store.json.put("decisions", decision);
107
+ return { status: "written", decision, provider: provider.name };
108
+ }
109
+ /** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
110
+ export async function recordFailure(store, root, failure) {
111
+ const symbols = store.json.loadAll("symbols");
112
+ const ranked = rankSuspects(symbols, failure.message);
113
+ // Prefer symbols actually named in the failure — so unrelated failures don't
114
+ // get the same boilerplate suspect list (which would fake a recurrence).
115
+ const msg = failure.message.toLowerCase();
116
+ const mentioned = ranked.filter((s) => msg.includes(s.name.toLowerCase()));
117
+ const suspects = (mentioned.length ? mentioned : ranked).slice(0, 6);
118
+ const provider = await selectProvider();
119
+ const input = {
120
+ test: failure.test,
121
+ message: failure.message,
122
+ // recent commit diff gives the synthesizer context for the root-cause guess
123
+ recentDiff: failure.recentDiff ?? recentDiffFor(root),
124
+ suspects: suspects.map((s) => `${s.name} @ ${s.file}`),
125
+ };
126
+ const draft = await draftBugSafe(provider, input);
127
+ // Seed the id from the test id (stable), not the LLM title — one bug per test.
128
+ const id = bugId(failure.test);
129
+ // recurrence = a DIFFERENT prior bug with a similar symptom (not this same one).
130
+ // Query text mirrors the corpus side (title+symptom+root_cause) for symmetry.
131
+ const prior = findRecurrence(store, `${draft.title} ${draft.symptom} ${draft.root_cause}`, id);
132
+ const affectedFiles = [...new Set(suspects.map((s) => s.file))];
133
+ const bug = {
134
+ id,
135
+ title: draft.title,
136
+ symptom: draft.symptom,
137
+ root_cause: draft.root_cause,
138
+ severity: draft.severity,
139
+ status: "open",
140
+ affected_files: affectedFiles,
141
+ affected_symbols: suspects.map((s) => s.id),
142
+ lineage: {
143
+ introduced_commit: null,
144
+ detected: failure.test,
145
+ fixed_commit: null,
146
+ recurrence_of: prior?.id ?? null,
147
+ spawned_decision: null,
148
+ spawned_constraint: null,
149
+ },
150
+ provenance: {
151
+ source: draft.source,
152
+ confidence: draft.confidence,
153
+ evidence: [`test:${failure.test}`, ...affectedFiles.slice(0, 6)],
154
+ },
155
+ };
156
+ store.json.put("bugs", bug);
157
+ // Promotion (DESIGN §4): a recurrence or a SUBSTANTIATED high-severity bug raises
158
+ // a regression Constraint to stop it coming back, and bumps fragility.
159
+ let constraint;
160
+ if (shouldPromoteConstraint(draft.severity, bug.root_cause, !!prior)) {
161
+ constraint = promoteConstraint(store, bug);
162
+ bug.lineage.spawned_constraint = constraint.id;
163
+ store.json.put("bugs", bug); // re-persist with the link
164
+ }
165
+ raiseFragility(store, affectedFiles);
166
+ return { status: "written", bug, constraint, provider: provider.name };
167
+ }
168
+ /** Whether a bug should auto-promote a regression Constraint (a do-not-break
169
+ * invariant). A recurrence always does. Otherwise it must be high/critical AND
170
+ * substantiated by a real root cause — a bare severity label with no analysis
171
+ * (e.g. an LLM "test_failure+llm_partial" draft) keeps its severity on the bug
172
+ * record for human review but must not silently mint an invariant from thin air. */
173
+ export function shouldPromoteConstraint(severity, rootCause, isRecurrence) {
174
+ if (isRecurrence)
175
+ return true;
176
+ const severe = severity === "high" || severity === "critical";
177
+ return severe && rootCause.trim().length > 0;
178
+ }
179
+ /** Turn a bug into an advisory regression constraint scoped to its files. */
180
+ function promoteConstraint(store, bug) {
181
+ const scope = bug.affected_files.length ? bug.affected_files : ["**"];
182
+ const statement = `Regression guard: "${bug.title}" must not recur.`;
183
+ const con = {
184
+ id: constraintId(statement),
185
+ type: bug.severity === "critical" ? "security" : "correctness",
186
+ statement,
187
+ scope,
188
+ severity: bug.severity === "critical" ? "blocking" : "warning",
189
+ enforcement: "advisory_v1",
190
+ rationale: `Derived from ${bug.id}: ${bug.root_cause || bug.symptom}`,
191
+ source_decision: null,
192
+ violations: [],
193
+ provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
194
+ };
195
+ return store.json.put("constraints", con);
196
+ }
197
+ /** Bump fragility on components owning the affected files. */
198
+ function raiseFragility(store, files) {
199
+ const comps = store.json.loadAll("components");
200
+ for (const c of comps) {
201
+ if (files.some((f) => c.paths.some((g) => pathMatchesGlob(f, g)))) {
202
+ const next = Math.min(1, Math.round((c.fragility + 0.1) * 100) / 100);
203
+ if (next !== c.fragility)
204
+ store.json.put("components", { ...c, fragility: next, updated_at: new Date().toISOString() });
205
+ }
206
+ }
207
+ }
208
+ /** churn × recency × fan_in, boosted if the symbol name appears in the message. */
209
+ function rankSuspects(symbols, message) {
210
+ const msg = message.toLowerCase();
211
+ return symbols
212
+ .map((s) => {
213
+ const mentioned = msg.includes(s.name.toLowerCase()) || msg.includes(s.file.toLowerCase());
214
+ const score = (s.metrics.churn_90d + 1) * (s.metrics.fan_in + 1) * (mentioned ? 5 : 1);
215
+ return { s, score };
216
+ })
217
+ .sort((a, b) => b.score - a.score)
218
+ .map((x) => x.s);
219
+ }
220
+ // Boilerplate/stopwords that must not drive recurrence matching — includes the
221
+ // deterministic provider's structural tokens (spec/suspected/src/...) and common
222
+ // test/JS filler, so two unrelated failures can't "match" on boilerplate alone.
223
+ const STOPWORDS = new Set([
224
+ "test", "failure", "error", "the", "a", "an", "and", "or", "of", "to", "in", "on", "for",
225
+ "with", "is", "was", "after", "before", "returned", "return", "valid", "expected", "actual",
226
+ // deterministic-provider / path boilerplate
227
+ "spec", "suspected", "src", "lib", "index", "unknown", "available", "llm",
228
+ // generic JS/test filler
229
+ "threw", "throws", "thrown", "null", "undefined", "property", "object", "cannot",
230
+ "read", "value", "failed", "assert", "assertion", "function", "type", "string", "number",
231
+ ]);
232
+ /** Neutralize boilerplate that would otherwise drive recurrence matching:
233
+ * - the deterministic provider's synthetic "Suspected in: a @ p, b @ q" list
234
+ * (the SAME churn-ranked suspects get injected into every no-mention failure,
235
+ * so two unrelated bugs would falsely share those identifier names), and
236
+ * - file-path tails ("... @ src/x.ts"). */
237
+ function stripPaths(text) {
238
+ return text
239
+ // Anchored to the deterministic provider's structural shape
240
+ // ("Suspected in: <ident> @ <path>, ...") so we strip the synthetic suspect
241
+ // list but NOT legitimate prose that merely contains the phrase.
242
+ .replace(/suspected in:\s*[\w$]+\s*@[^\n]*/gi, " ")
243
+ .replace(/@\s*\S+/g, " ")
244
+ .replace(/[\w./-]+\.(ts|tsx|js|jsx|mts|cts)\b/g, " ");
245
+ }
246
+ export function salientTerms(text) {
247
+ const out = new Set();
248
+ for (const t of stripPaths(text).toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? []) {
249
+ if (t.length > 2 && !STOPWORDS.has(t))
250
+ out.add(t);
251
+ }
252
+ return out;
253
+ }
254
+ /** Recurrence = a DIFFERENT prior bug whose salient terms overlap strongly with
255
+ * this one (in-memory, no FTS/reindex dependency, threshold-gated to avoid the
256
+ * over-broad OR false positives). Returns the best match above threshold. */
257
+ function findRecurrence(store, text, excludeId) {
258
+ const want = salientTerms(text);
259
+ if (want.size === 0)
260
+ return undefined;
261
+ let best;
262
+ let bestScore = 0;
263
+ for (const b of store.json.loadAll("bugs")) {
264
+ if (b.id === excludeId)
265
+ continue;
266
+ // symmetric with the query side (which now also includes root_cause)
267
+ const have = salientTerms(`${b.title} ${b.symptom} ${b.root_cause}`);
268
+ if (have.size === 0)
269
+ continue;
270
+ let shared = 0;
271
+ for (const t of want)
272
+ if (have.has(t))
273
+ shared++;
274
+ const jaccard = shared / (want.size + have.size - shared);
275
+ // Scale-aware gate: small term sets need MORE shared terms so coincidental
276
+ // overlap (a couple of common words) can't masquerade as a recurrence —
277
+ // EXCEPT when the overlap is near-total (jaccard >= 0.8), which means the two
278
+ // texts are nearly identical and a terse 2-term match is a real recurrence.
279
+ const minSize = Math.min(want.size, have.size);
280
+ const minShared = jaccard >= 0.8 ? 2 : Math.max(3, Math.ceil(0.5 * minSize));
281
+ if (shared >= minShared && jaccard >= 0.4 && jaccard > bestScore) {
282
+ best = b;
283
+ bestScore = jaccard;
284
+ }
285
+ }
286
+ return best;
287
+ }
288
+ /** Best-effort recent commit diff for failure context (empty if not a git repo). */
289
+ function recentDiffFor(root) {
290
+ const head = headSha(root);
291
+ return head ? commitDiff(head, root, 12_000) : "";
292
+ }
293
+ export async function draftDecisionSafe(provider, input) {
294
+ try {
295
+ return await provider.draftDecision(input);
296
+ }
297
+ catch {
298
+ // A provider failure (network, bad creds, CLI crash, unparseable output) must
299
+ // never abort the learning loop — fall back to the deterministic heuristic
300
+ // draft, which is honestly labeled ("inferred") rather than a hollow llm_draft.
301
+ return new DeterministicProvider().draftDecision(input);
302
+ }
303
+ }
304
+ export async function draftBugSafe(provider, input) {
305
+ try {
306
+ return await provider.draftBug(input);
307
+ }
308
+ catch {
309
+ return new DeterministicProvider().draftBug(input);
310
+ }
311
+ }
312
+ //# sourceMappingURL=synthesize.js.map
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@davesheffer/hunch",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
+ "description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
7
+ "homepage": "https://hunch.sh",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/davesheffer/hunch.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/davesheffer/hunch/issues"
14
+ },
15
+ "type": "module",
16
+ "bin": {
17
+ "hunch": "dist/cli/index.js"
18
+ },
19
+ "files": [
20
+ "dist/**/*.js"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "keywords": [
26
+ "claude-code",
27
+ "mcp",
28
+ "engineering-memory",
29
+ "knowledge-graph",
30
+ "code-intelligence",
31
+ "ai",
32
+ "developer-tools"
33
+ ],
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "scripts": {
38
+ "clean": "node --input-type=commonjs -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
39
+ "build": "npm run clean && tsc -p tsconfig.json",
40
+ "dev": "tsx src/cli/index.ts",
41
+ "hunch": "tsx src/cli/index.ts",
42
+ "test": "tsx --test test/*.test.ts",
43
+ "typecheck": "tsc -p tsconfig.json --noEmit",
44
+ "prepublishOnly": "npm run build"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.29.0",
48
+ "better-sqlite3": "12.9.0",
49
+ "commander": "^15.0.0",
50
+ "tree-sitter": "0.21.1",
51
+ "tree-sitter-typescript": "^0.23.2",
52
+ "zod": "^4.4.3"
53
+ },
54
+ "devDependencies": {
55
+ "@types/better-sqlite3": "^7.6.13",
56
+ "@types/node": "^20.19.0",
57
+ "tsx": "^4.22.4",
58
+ "typescript": "^5.9.3"
59
+ },
60
+ "peerDependencies": {
61
+ "@huggingface/transformers": ">=3"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@huggingface/transformers": {
65
+ "optional": true
66
+ }
67
+ }
68
+ }