@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,587 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `hunch` CLI (DESIGN.md ยง6). Subcommands:
4
+ * init scaffold .hunch/, install hook, write .mcp.json + CLAUDE.md + slash cmds
5
+ * index parse repo -> symbol/dependency graph + components (no LLM)
6
+ * backfill replay git history -> seed decisions (cold-start fix)
7
+ * sync commit diff -> Claude/heuristic -> decision write-back (post-commit hook)
8
+ * query FTS + graph query over Hunch
9
+ * why decisions/bugs/constraints explaining a file/symbol
10
+ * fragile ranked fragility report with evidence
11
+ * record-bug capture a Bug from a (failing) test
12
+ * mcp start the MCP server (Claude Code connects here)
13
+ * doctor environment diagnostics
14
+ */
15
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { execFileSync } from "node:child_process";
17
+ import { Command } from "commander";
18
+ import { hunchPaths, findRoot } from "../core/paths.js";
19
+ import { HunchStore } from "../store/hunchStore.js";
20
+ import { selectEmbedder } from "../store/embedder.js";
21
+ import { indexRepo } from "../extractors/indexer.js";
22
+ import { syncCommit, recordFailure } from "../synthesis/synthesize.js";
23
+ import { selectProvider } from "../synthesis/provider.js";
24
+ import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles } from "../extractors/git.js";
25
+ import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
26
+ import { installMergeDriver } from "../integrations/mergeDriver.js";
27
+ import { updateClaudeMd } from "../integrations/claudemd.js";
28
+ import { writeMcpJson, writeSlashCommands } from "../integrations/scaffold.js";
29
+ import { formatContext } from "../core/format.js";
30
+ import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
31
+ import { mergeHunchJson } from "../store/merge.js";
32
+ import { planCompaction } from "../store/compact.js";
33
+ import { resolveInvocation } from "./invocation.js";
34
+ const program = new Command();
35
+ program.name("hunch").description("Hunch โ€” an Engineering Memory OS: a git-native reasoning graph for your codebase.").version("0.1.0");
36
+ let openStore = null;
37
+ function storeFor() {
38
+ const root = findRoot();
39
+ const store = new HunchStore(hunchPaths(root));
40
+ openStore = store;
41
+ return { store, root };
42
+ }
43
+ // ---- init -----------------------------------------------------------------
44
+ program
45
+ .command("init")
46
+ .description("Scaffold .hunch/, index the repo, install the git hook, and wire up Claude Code.")
47
+ .option("--no-index", "skip the initial repo index")
48
+ .option("--enforce", "install an advisory pre-commit constraint guard")
49
+ .option("--enforce-strict", "install a pre-commit guard that FAILS the commit on a blocking invariant")
50
+ .action((opts) => {
51
+ const root = findRoot();
52
+ const paths = hunchPaths(root);
53
+ const store = new HunchStore(paths);
54
+ openStore = store; // so the top-level error handler closes it on failure
55
+ const inv = resolveInvocation();
56
+ console.log(`๐Ÿง  Initializing Hunch at ${root}`);
57
+ store.json.ensureDirs(); // stamps the manifest at the current version when fresh
58
+ console.log(` โœ“ .hunch/ scaffolded (schema v${readManifest(paths).schema_version})`);
59
+ if (opts.index !== false) {
60
+ const res = indexRepo(store, root);
61
+ store.reindex();
62
+ console.log(` โœ“ indexed ${res.files} files โ†’ ${res.symbols} symbols, ${res.edges} edges, ${res.components} components`);
63
+ if (res.skipped)
64
+ console.log(` โš  ${res.skipped} file(s) could not be parsed (skipped)`);
65
+ }
66
+ if (isGitRepo(root)) {
67
+ const h = installPostCommitHook(root, inv.shell);
68
+ console.log(` โœ“ post-commit hook ${h.action} (learning loop)`);
69
+ const m = installMergeDriver(root, inv.shell);
70
+ console.log(` โœ“ team merge driver ${m.action}`);
71
+ if (opts.enforce || opts.enforceStrict) {
72
+ const p = installPreCommitHook(root, inv.shell, !!opts.enforceStrict);
73
+ console.log(` โœ“ pre-commit constraint guard ${p.action} (${opts.enforceStrict ? "strict โ€” blocks on blocking invariants" : "advisory"})`);
74
+ }
75
+ }
76
+ else {
77
+ console.log(" โš  not a git repo โ€” skipped hooks (run `git init` to enable the learning loop)");
78
+ }
79
+ const mcp = writeMcpJson(root, inv.mcp);
80
+ console.log(` โœ“ wrote ${rel(root, mcp)} (registers the Hunch MCP server)`);
81
+ const cmds = writeSlashCommands(root);
82
+ console.log(` โœ“ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
83
+ const cmd = updateClaudeMd(root, store);
84
+ console.log(` โœ“ updated ${rel(root, cmd)} with ambient Hunch context`);
85
+ store.close();
86
+ console.log("\nNext: make a commit (the hook captures a decision), then ask Claude Code \"why is X built this way?\"");
87
+ console.log("Cold start? Seed from history: hunch backfill --since 90d");
88
+ });
89
+ // ---- index ----------------------------------------------------------------
90
+ program
91
+ .command("index")
92
+ .description("Parse the repo into a symbol/dependency graph + components (deterministic, no LLM).")
93
+ .action(() => {
94
+ const { store, root } = storeFor();
95
+ store.json.ensureDirs();
96
+ const res = indexRepo(store, root);
97
+ const { counts } = store.reindex();
98
+ updateClaudeMd(root, store);
99
+ console.log(`Indexed ${res.files} files:`);
100
+ console.log(` ${counts.symbols} symbols, ${counts.edges} edges, ${counts.components} components`);
101
+ if (res.skipped)
102
+ console.log(` โš  ${res.skipped} file(s) could not be parsed (skipped)`);
103
+ store.close();
104
+ });
105
+ // ---- backfill -------------------------------------------------------------
106
+ program
107
+ .command("backfill")
108
+ .description("Replay git history to seed decisions (cold-start fix).")
109
+ .option("--since <spec>", "how far back, e.g. 90d", "90d")
110
+ .option("--max <n>", "max commits to process", "40")
111
+ .action(async (opts) => {
112
+ const { store, root } = storeFor();
113
+ if (!isGitRepo(root))
114
+ return fail("backfill needs a git repo");
115
+ store.json.ensureDirs();
116
+ const commits = logSince(opts.since, root, Number(opts.max));
117
+ console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since}โ€ฆ`);
118
+ let written = 0, skipped = 0, llm = 0, heuristic = 0;
119
+ for (const sha of commits) {
120
+ const r = await syncCommit(store, root, sha);
121
+ if (r.status === "written") {
122
+ written++;
123
+ if (r.provider === "claude-cli")
124
+ llm++;
125
+ else
126
+ heuristic++;
127
+ process.stdout.write(` โœ“ ${sha.slice(0, 8)} ${r.decision?.title.slice(0, 64) ?? ""}\n`);
128
+ }
129
+ else
130
+ skipped++;
131
+ }
132
+ store.reindex();
133
+ updateClaudeMd(root, store);
134
+ // Honest tally of where the tokens went: trivial commits are seeded by the
135
+ // free deterministic heuristic, only substantive ones spend the LLM.
136
+ console.log(`Done: ${written} decision(s) seeded (${llm} via LLM, ${heuristic} heuristic), ${skipped} skipped (trivial/non-code/already-captured).`);
137
+ store.close();
138
+ });
139
+ // ---- sync (post-commit hook) ----------------------------------------------
140
+ program
141
+ .command("sync")
142
+ .description("Capture a decision from a commit (run by the post-commit hook).")
143
+ .argument("[sha]", "commit to sync (default: HEAD)")
144
+ .option("--from-hook", "invoked by the git hook")
145
+ .option("--quiet", "minimal output")
146
+ .option("--force", "re-synthesize even if a decision already exists for the commit")
147
+ .action(async (sha, opts) => {
148
+ const { store, root } = storeFor();
149
+ if (!isGitRepo(root))
150
+ return opts.quiet ? undefined : fail("sync needs a git repo");
151
+ store.json.ensureDirs();
152
+ const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force });
153
+ if (r.status === "written") {
154
+ store.reindex();
155
+ // Don't rewrite CLAUDE.md from the hook โ€” it would dirty the working tree
156
+ // on every commit. `hunch index`/`init` refresh it intentionally instead.
157
+ if (!opts.fromHook)
158
+ updateClaudeMd(root, store);
159
+ if (!opts.quiet)
160
+ console.log(`โœ“ captured decision ${r.decision?.id} via ${r.provider}: "${r.decision?.title}"`);
161
+ }
162
+ else if (!opts.quiet) {
163
+ console.log(`ยท skipped: ${r.reason}`);
164
+ }
165
+ store.close();
166
+ });
167
+ // ---- query ----------------------------------------------------------------
168
+ program
169
+ .command("query")
170
+ .description("Full-text + graph search over Hunch (add --semantic for embeddings-backed recall).")
171
+ .argument("<question...>", "what to search for")
172
+ .option("--semantic", "blend in local semantic search (requires `hunch embed`)")
173
+ .action(async (parts, opts) => {
174
+ const { store } = storeFor();
175
+ store.reindex(); // reflect any out-of-band JSON edits before searching
176
+ const q = parts.join(" ");
177
+ let hits;
178
+ let how = "";
179
+ if (opts.semantic) {
180
+ // Resolve once; if semantic isn't actually usable, say so and degrade to FTS
181
+ // (rather than silently returning identical keyword results under a flag).
182
+ const emb = await selectEmbedder();
183
+ const cov = emb ? store.embeddingStats(emb.id) : null;
184
+ if (!emb || !cov || cov.embedded === 0) {
185
+ console.log("ยท semantic search isn't enabled yet โ€” run `hunch embed` (using keyword search for now).\n");
186
+ hits = store.search(q, 12);
187
+ }
188
+ else {
189
+ hits = await store.hybridSearch(q, 12, { embedder: emb });
190
+ how = " (semantic + keyword)";
191
+ }
192
+ }
193
+ else {
194
+ hits = store.search(q, 12);
195
+ }
196
+ if (!hits.length) {
197
+ console.log(`No matches for "${q}".`);
198
+ }
199
+ else {
200
+ console.log(`Top matches for "${q}"${how}:\n`);
201
+ for (const h of hits)
202
+ console.log(`โ€ข [${h.kind}] ${h.ref} โ€” ${h.title}\n ${h.snippet}`);
203
+ }
204
+ store.close();
205
+ });
206
+ // ---- embed (opt-in semantic search) ---------------------------------------
207
+ program
208
+ .command("embed")
209
+ .description("Generate local embeddings for semantic search (opt-in; needs @huggingface/transformers).")
210
+ .option("--batch <n>", "embedding batch size", "32")
211
+ .action(async (opts) => {
212
+ const { store } = storeFor();
213
+ const embedder = await selectEmbedder();
214
+ if (!embedder) {
215
+ console.log("Semantic search needs a local embedding model, which isn't installed.");
216
+ console.log(" Enable it: npm i -g @huggingface/transformers (then re-run `hunch embed`)");
217
+ console.log(" Until then, `hunch query` uses fast keyword (FTS) search โ€” no setup needed.");
218
+ store.close();
219
+ return; // not an error: the lean install simply doesn't have semantic search
220
+ }
221
+ store.json.ensureDirs();
222
+ store.reindex(); // make `search` + doc hashes current before embedding
223
+ const stats = store.embeddingStats(embedder.id);
224
+ const todo = stats.total - stats.embedded;
225
+ if (todo === 0) {
226
+ console.log(`โœ“ All ${stats.total} doc(s) already embedded (model ${embedder.id}). Nothing to do.`);
227
+ store.close();
228
+ return;
229
+ }
230
+ process.stdout.write(`Embedding ${todo} doc(s) with ${embedder.id} (first run downloads the model ~90MB, one time)โ€ฆ\n`);
231
+ const res = await store.embedAll(embedder, {
232
+ batch: Number(opts.batch),
233
+ onProgress: (done, total) => process.stdout.write(`\r ${done}/${total} embedded `),
234
+ });
235
+ process.stdout.write("\n");
236
+ console.log(`โœ“ embedded ${res.embedded} doc(s) (${res.skipped} already current). Model: ${embedder.id}.`);
237
+ console.log(` Try: hunch query --semantic "<question>" โ€” the MCP server uses it automatically.`);
238
+ store.close();
239
+ });
240
+ // ---- why ------------------------------------------------------------------
241
+ program
242
+ .command("why")
243
+ .description("Explain why a file/symbol is the way it is (decisions, bugs, constraints).")
244
+ .argument("<target>", "file path or symbol name")
245
+ .action((target) => {
246
+ const { store, root } = storeFor();
247
+ const w = store.why(target);
248
+ const staleIds = new Set(store.staleness((f) => lastChangeDate(f, root)).map((s) => s.id));
249
+ const drift = (id) => (staleIds.has(id) ? " โš STALE" : "");
250
+ console.log(`Why "${target}":\n`);
251
+ if (w.decisions.length) {
252
+ console.log("DECISIONS:");
253
+ for (const d of w.decisions)
254
+ console.log(` โ€ข ${d.id} [${d.status}]${drift(d.id)} ${d.title}\n ${d.decision} โŸจ${d.provenance.source}, ${d.provenance.confidence}โŸฉ`);
255
+ }
256
+ if (w.constraints.length) {
257
+ console.log("CONSTRAINTS (must not break):");
258
+ for (const c of w.constraints)
259
+ console.log(` โ€ข ${c.id} [${c.severity}]${drift(c.id)} ${c.statement}`);
260
+ }
261
+ if (w.bugs.length) {
262
+ console.log("BUG HISTORY:");
263
+ for (const b of w.bugs)
264
+ console.log(` โ€ข ${b.id} [${b.status}] ${b.title} โ€” ${b.root_cause}`);
265
+ }
266
+ if (!w.decisions.length && !w.constraints.length && !w.bugs.length) {
267
+ console.log("(No recorded decisions/bugs/constraints yet. Try `hunch backfill` or make a commit.)");
268
+ }
269
+ if (w.symbols.length)
270
+ console.log(`\nSYMBOLS: ${w.symbols.map((s) => `${s.name} [fan-in ${s.metrics.fan_in}, churn ${s.metrics.churn_90d}]`).join(", ")}`);
271
+ store.close();
272
+ });
273
+ // ---- fragile --------------------------------------------------------------
274
+ program
275
+ .command("fragile")
276
+ .description("Ranked fragility report with evidence.")
277
+ .option("--limit <n>", "how many", "15")
278
+ .action((opts) => {
279
+ const { store } = storeFor();
280
+ const nodes = store.fragility(Number(opts.limit));
281
+ if (!nodes.length) {
282
+ console.log("No fragility signal yet (index the repo and accumulate bug history first).");
283
+ }
284
+ else {
285
+ console.log("Most fragile symbols (fragility = bugs ร— churn ร— centrality):\n");
286
+ for (const n of nodes)
287
+ console.log(` ${n.score.toFixed(2)} ${n.name} ${dim(n.file)}\n ${n.evidence.join(" ยท ") || "โ€”"}`);
288
+ }
289
+ store.close();
290
+ });
291
+ // ---- record-bug -----------------------------------------------------------
292
+ program
293
+ .command("record-bug")
294
+ .description("Capture a Bug from a failing test (symptom + suspect ranking).")
295
+ .requiredOption("--test <id>", "failing test id/name")
296
+ .requiredOption("--message <msg>", "failure message / stack")
297
+ .action(async (opts) => {
298
+ const { store, root } = storeFor();
299
+ store.json.ensureDirs();
300
+ const r = await recordFailure(store, root, { test: opts.test, message: opts.message });
301
+ store.reindex();
302
+ console.log(`โœ“ recorded bug ${r.bug.id} via ${r.provider}: "${r.bug.title}"`);
303
+ if (r.bug.lineage.recurrence_of)
304
+ console.log(` โ†ณ recurrence of ${r.bug.lineage.recurrence_of}`);
305
+ if (r.constraint)
306
+ console.log(` โ†ณ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
307
+ store.close();
308
+ });
309
+ // ---- stale (drift detection) ----------------------------------------------
310
+ program
311
+ .command("stale")
312
+ .description("List decisions/constraints whose files changed after they were last verified (drift).")
313
+ .action(() => {
314
+ const { store, root } = storeFor();
315
+ const stale = store.staleness((f) => lastChangeDate(f, root));
316
+ if (!stale.length) {
317
+ console.log("โœ“ No drift detected โ€” every verified decision/constraint is current.");
318
+ }
319
+ else {
320
+ console.log(`โš  ${stale.length} record(s) may be stale (a file in scope changed after last verification):\n`);
321
+ for (const s of stale) {
322
+ console.log(` ${s.kind} ${s.id}\n verified ${s.last_verified.slice(0, 10)} ยท changed ${s.changed_at.slice(0, 10)} ยท ${s.files.join(", ")}`);
323
+ }
324
+ console.log(`\nRe-validate with: hunch review --accept <id> (or edit the record).`);
325
+ }
326
+ store.close();
327
+ });
328
+ // ---- check (constraint enforcement) ---------------------------------------
329
+ program
330
+ .command("check")
331
+ .description("Flag changes that touch a do-not-break invariant's scope (guardrail).")
332
+ .option("--staged", "check git staged files (default)")
333
+ .option("--commit <sha>", "check a specific commit's files")
334
+ .option("--strict", "exit non-zero if a blocking constraint is in scope")
335
+ .action((opts) => {
336
+ if (opts.commit && opts.staged)
337
+ return fail("--staged and --commit are mutually exclusive");
338
+ const { store, root } = storeFor();
339
+ const files = opts.commit ? commitFiles(opts.commit, root) : stagedFiles(root);
340
+ if (!files.length) {
341
+ console.log("No changed files to check.");
342
+ store.close();
343
+ return;
344
+ }
345
+ const hits = new Map();
346
+ for (const f of files) {
347
+ for (const c of store.checkConstraints(f)) {
348
+ const e = hits.get(c.id) ?? { constraint: c, files: [] };
349
+ e.files.push(f);
350
+ hits.set(c.id, e);
351
+ }
352
+ }
353
+ if (!hits.size) {
354
+ console.log(`โœ“ ${files.length} changed file(s) touch no recorded invariants.`);
355
+ store.close();
356
+ return;
357
+ }
358
+ let blocking = 0;
359
+ console.log(`Changes touch ${hits.size} invariant(s):\n`);
360
+ for (const { constraint: c, files: fs } of hits.values()) {
361
+ if (c.severity === "blocking")
362
+ blocking++;
363
+ const mark = c.severity === "blocking" ? "โ›”" : c.severity === "warning" ? "โš " : "ยท";
364
+ console.log(` ${mark} [${c.severity}] ${c.statement}\n ${c.id} ยท in: ${fs.join(", ")}\n rationale: ${c.rationale || "โ€”"}`);
365
+ }
366
+ if (opts.strict && blocking) {
367
+ console.log(`\nโœ— ${blocking} blocking invariant(s) in scope โ€” review before committing.`);
368
+ process.exitCode = 1;
369
+ }
370
+ else {
371
+ console.log(`\nReview that these invariants still hold. (Advisory โ€” run with --strict to fail on blocking.)`);
372
+ }
373
+ store.close();
374
+ });
375
+ // ---- context (surgical retrieval) -----------------------------------------
376
+ program
377
+ .command("context")
378
+ .description("Assemble the minimal relevant Hunch slice for a task on a file/symbol.")
379
+ .argument("<target>", "file path or symbol")
380
+ .option("--budget <n>", "rough token budget", "1500")
381
+ .action((target, opts) => {
382
+ const { store } = storeFor();
383
+ store.reindex(); // reflect any out-of-band JSON edits before assembling
384
+ process.stdout.write(formatContext(store.assembleContext(target, Number(opts.budget))));
385
+ store.close();
386
+ });
387
+ // ---- review (curate loop) -------------------------------------------------
388
+ program
389
+ .command("review")
390
+ .description("Triage low-confidence drafts: list, accept (promote), or reject.")
391
+ .option("--accept <id>", "promote a decision to accepted/human-confirmed")
392
+ .option("--reject <id>", "delete a draft decision")
393
+ .action((opts) => {
394
+ const { store, root } = storeFor();
395
+ if (opts.accept) {
396
+ const d = store.json.get("decisions", opts.accept);
397
+ if (!d)
398
+ return fail(`decision ${opts.accept} not found`);
399
+ const source = d.provenance.source.includes("llm_draft") ? "llm_draft+human_confirmed" : "human_confirmed";
400
+ store.json.put("decisions", { ...d, status: "accepted", provenance: { ...d.provenance, source, confidence: 0.95, last_verified: new Date().toISOString() } });
401
+ store.reindex();
402
+ updateClaudeMd(root, store);
403
+ console.log(`โœ“ accepted ${opts.accept} (now ${source}, confidence 0.95)`);
404
+ }
405
+ else if (opts.reject) {
406
+ const ok2 = store.json.delete("decisions", opts.reject);
407
+ store.reindex();
408
+ console.log(ok2 ? `โœ“ rejected and removed ${opts.reject}` : `decision ${opts.reject} not found`);
409
+ }
410
+ else {
411
+ const drafts = store.json.loadAll("decisions")
412
+ .filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6)
413
+ .sort((a, b) => a.provenance.confidence - b.provenance.confidence);
414
+ if (!drafts.length) {
415
+ console.log("โœ“ No low-confidence drafts to review.");
416
+ }
417
+ else {
418
+ console.log(`${drafts.length} draft(s) awaiting review (lowest confidence first):\n`);
419
+ for (const d of drafts) {
420
+ console.log(` ${d.id} [${d.status}, ${d.provenance.source} ${d.provenance.confidence}]\n ${d.title}\n ${d.decision.slice(0, 120)}`);
421
+ }
422
+ console.log(`\nAccept: hunch review --accept <id>\nReject: hunch review --reject <id>`);
423
+ }
424
+ }
425
+ store.close();
426
+ });
427
+ // ---- mcp ------------------------------------------------------------------
428
+ program
429
+ .command("mcp")
430
+ .description("Start the MCP server over stdio (Claude Code connects here).")
431
+ .action(async () => {
432
+ const { startServer } = await import("../mcp/server.js");
433
+ await startServer(process.cwd());
434
+ });
435
+ // ---- migrate (schema versioning) ------------------------------------------
436
+ program
437
+ .command("migrate")
438
+ .description("Upgrade .hunch/ records to the current schema version and stamp the manifest.")
439
+ .action(() => {
440
+ const root = findRoot();
441
+ const paths = hunchPaths(root);
442
+ const store = new HunchStore(paths);
443
+ openStore = store;
444
+ const from = readManifest(paths).schema_version;
445
+ if (from > SCHEMA_VERSION) {
446
+ store.close();
447
+ return fail(`.hunch/ is schema v${from}, newer than this hunch (v${SCHEMA_VERSION}). Upgrade hunch.`);
448
+ }
449
+ if (from === SCHEMA_VERSION) {
450
+ writeManifest(paths, SCHEMA_VERSION); // record the version even if the manifest was absent
451
+ console.log(`โœ“ Already at schema v${SCHEMA_VERSION} โ€” nothing to migrate.`);
452
+ store.close();
453
+ return;
454
+ }
455
+ const res = store.json.persistMigration();
456
+ writeManifest(paths, SCHEMA_VERSION);
457
+ store.reindex();
458
+ console.log(`โœ“ Migrated v${from} โ†’ v${SCHEMA_VERSION}: ${res.migrated} record(s) upgraded.`);
459
+ if (res.skipped) {
460
+ console.warn(`โš  ${res.skipped} record(s) could NOT be migrated and will no longer load. They are preserved on disk in their old shape under .hunch/ for manual recovery.`);
461
+ }
462
+ store.close();
463
+ });
464
+ // ---- compact (bound Hunch growth) -----------------------------------------
465
+ program
466
+ .command("compact")
467
+ .description("Prune low-value auto-captured records (rejected/superseded/stale drafts, resolved low-confidence bugs).")
468
+ .option("--apply", "actually delete (default: dry-run preview)")
469
+ .option("--max-age <days>", "minimum age in days for stale-draft pruning", "180")
470
+ .option("--min-confidence <n>", "confidence below which a draft is prunable", "0.35")
471
+ .action((opts) => {
472
+ const { store, root } = storeFor();
473
+ const plan = planCompaction({ decisions: store.json.loadAll("decisions"), bugs: store.json.loadAll("bugs"), constraints: store.json.loadAll("constraints") }, { now: Date.now(), maxAgeDays: Number(opts.maxAge), minConfidence: Number(opts.minConfidence) });
474
+ if (!plan.remove.length) {
475
+ console.log(`โœ“ Nothing to compact (${plan.considered} record(s) considered; accepted/open/referenced records are always kept).`);
476
+ store.close();
477
+ return;
478
+ }
479
+ console.log(`${plan.remove.length} of ${plan.considered} record(s) ${opts.apply ? "removed" : "would be removed"}:\n`);
480
+ for (const c of plan.remove)
481
+ console.log(` ${opts.apply ? "โœ—" : "ยท"} [${c.kind}] ${c.id} ${c.title}\n ${c.reason}`);
482
+ if (opts.apply) {
483
+ let removed = 0;
484
+ for (const c of plan.remove)
485
+ if (store.json.delete(c.kind, c.id))
486
+ removed++;
487
+ store.reindex();
488
+ updateClaudeMd(root, store);
489
+ console.log(`\nโœ“ Removed ${removed} record(s).`);
490
+ }
491
+ else {
492
+ console.log(`\nDry run โ€” re-run with --apply to delete. Accepted/human-confirmed, open bugs, constraints, and referenced records are never removed.`);
493
+ }
494
+ store.close();
495
+ });
496
+ // ---- merge-driver (internal; git invokes this) ----------------------------
497
+ program
498
+ .command("merge-driver")
499
+ .description("(internal) git merge driver for .hunch JSON โ€” resolves concurrent edits by record id.")
500
+ .argument("<base>", "%O โ€” common ancestor")
501
+ .argument("<ours>", "%A โ€” current branch (also the OUTPUT file)")
502
+ .argument("<theirs>", "%B โ€” other branch")
503
+ .argument("[path]", "%P โ€” pathname being merged")
504
+ .action((base, ours, theirs) => {
505
+ const read = (p) => (existsSync(p) ? readFileSync(p, "utf8") : "");
506
+ const res = mergeHunchJson(read(base), read(ours), read(theirs));
507
+ if (!res.conflict) {
508
+ writeFileSync(ours, res.text); // %A is the merge output git reads back
509
+ return;
510
+ }
511
+ // Couldn't structurally merge (corrupt JSON / id-less / id divergence). Fall
512
+ // back to git's own 3-way text merge so %A gets STANDARD conflict markers โ€”
513
+ // never silently leave `ours` and hide `theirs`. `git merge-file -p` prints the
514
+ // marked result to stdout and exits non-zero when markers remain.
515
+ const lf = (s) => s.replace(/\r\n/g, "\n"); // match the LF the structured path emits
516
+ try {
517
+ const merged = execFileSync("git", ["merge-file", "-p", "--diff3", "-L", "ours", "-L", "base", "-L", "theirs", ours, base, theirs], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
518
+ writeFileSync(ours, lf(merged)); // clean text merge โ†’ resolved
519
+ }
520
+ catch (e) {
521
+ const err = e;
522
+ const out = typeof err.stdout === "string" ? err.stdout : err.stdout?.toString();
523
+ if (out != null)
524
+ writeFileSync(ours, lf(out)); // marked result; else leave ours
525
+ process.exitCode = 1; // conflict markers remain โ†’ block the commit for review
526
+ }
527
+ });
528
+ // ---- doctor ---------------------------------------------------------------
529
+ program
530
+ .command("doctor")
531
+ .description("Diagnose the environment (git, synthesis provider, index freshness).")
532
+ .action(async () => {
533
+ const { store, root } = storeFor();
534
+ console.log(`Hunch root: ${root}`);
535
+ console.log(`git repo: ${isGitRepo(root) ? "yes" : "no"} ${isGitRepo(root) ? `(HEAD ${headSha(root).slice(0, 8)})` : ""}`);
536
+ const onDisk = readManifest(hunchPaths(root)).schema_version;
537
+ const schemaNote = onDisk === SCHEMA_VERSION ? "" : onDisk > SCHEMA_VERSION ? ` โš  newer than this Hunch (v${SCHEMA_VERSION}) โ€” upgrade hunch` : ` โš  run \`hunch migrate\``;
538
+ console.log(`schema: v${onDisk} (hunch v${SCHEMA_VERSION})${schemaNote}`);
539
+ const provider = await selectProvider();
540
+ console.log(`synthesis: ${provider.name}`);
541
+ // Synthesis is billed to the user's Claude SUBSCRIPTION via the `claude` CLI,
542
+ // never the pay-per-token API. Surface whatever stands between here and that.
543
+ if (provider.name === "claude-cli") {
544
+ const hadKey = !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
545
+ console.log(` โ†ณ LLM synthesis billed to your Claude subscription` +
546
+ (hadKey ? ` (ANTHROPIC_API_KEY in env is stripped โ€” never billed to the API)` : ``));
547
+ }
548
+ else if (provider.name === "deterministic") {
549
+ console.log(dim(` โ†ณ no \`claude\` CLI โ€” synthesis uses the offline heuristic (advisory, low-confidence)`));
550
+ console.log(dim(` for full synthesis: install Claude Code + \`claude /login\`, or set CLAUDE_CODE_OAUTH_TOKEN (\`claude setup-token\`) for CI`));
551
+ }
552
+ const c = store.reindex().counts;
553
+ console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
554
+ // Semantic search is opt-in and local. Report availability + coverage without
555
+ // loading the model (selectEmbedder only probes; embeddingStats just counts rows).
556
+ const emb = await selectEmbedder();
557
+ if (emb) {
558
+ const cov = store.embeddingStats(emb.id);
559
+ const hint = cov.embedded === 0 ? " โš  run `hunch embed`" : cov.embedded < cov.total ? " โš  stale โ€” re-run `hunch embed`" : "";
560
+ console.log(`semantic: ${emb.id} โ€” ${cov.embedded}/${cov.total} docs embedded${hint}`);
561
+ }
562
+ else {
563
+ console.log(dim(`semantic: off (keyword search only) โ€” enable: npm i -g @huggingface/transformers && hunch embed`));
564
+ }
565
+ store.close();
566
+ });
567
+ function rel(root, p) {
568
+ return p.startsWith(root) ? p.slice(root.length + 1) : p;
569
+ }
570
+ function dim(s) {
571
+ return `\x1b[2m${s}\x1b[0m`;
572
+ }
573
+ function fail(msg) {
574
+ console.error(`error: ${msg}`);
575
+ process.exitCode = 1;
576
+ }
577
+ program.parseAsync().catch((e) => {
578
+ try {
579
+ openStore?.close();
580
+ }
581
+ catch {
582
+ /* ignore */
583
+ }
584
+ console.error(`hunch: ${e instanceof Error ? e.message : String(e)}`);
585
+ process.exit(1);
586
+ });
587
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,23 @@
1
+ /** Figures out how to re-invoke this CLI from a git hook / .mcp.json, working
2
+ * both when running the built dist (plain node) and in dev via tsx. */
3
+ import { fileURLToPath } from "node:url";
4
+ export function resolveInvocation() {
5
+ const entry = fileURLToPath(import.meta.url).replace(/invocation\.(js|ts)$/, "index.$1");
6
+ const isDev = entry.endsWith(".ts");
7
+ // JSON.stringify yields a double-quoted, backslash-escaped token /bin/sh
8
+ // accepts โ€” so install paths with spaces don't break the hook command.
9
+ const q = (s) => JSON.stringify(s);
10
+ if (isDev) {
11
+ return {
12
+ shell: `npx tsx ${q(entry)}`,
13
+ mcp: { command: "npx", args: ["tsx", entry] },
14
+ };
15
+ }
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.
18
+ return {
19
+ shell: `${q(process.execPath)} ${q(entry)}`,
20
+ mcp: { command: process.execPath, args: [entry] },
21
+ };
22
+ }
23
+ //# sourceMappingURL=invocation.js.map
@@ -0,0 +1,46 @@
1
+ function prov(p) {
2
+ if (!p)
3
+ return "";
4
+ const v = p.last_verified ? `, verified ${p.last_verified.slice(0, 10)}` : "";
5
+ return ` โŸจ${p.source ?? "?"} ${p.confidence ?? "?"}${v}โŸฉ`;
6
+ }
7
+ export function formatContext(ctx) {
8
+ const out = [`# Hunch context for "${ctx.target}"`];
9
+ if (ctx.constraints.length) {
10
+ out.push(`\n## โ›” Invariants (must not break)`);
11
+ for (const c of ctx.constraints)
12
+ out.push(`- [${c.severity}] ${c.statement}${prov(c.provenance)}\n (${c.id}; scope ${c.scope.join(", ") || "repo"})`);
13
+ }
14
+ if (ctx.decisions.length) {
15
+ out.push(`\n## ๐Ÿงญ Decisions (why it's shaped this way)`);
16
+ for (const d of ctx.decisions)
17
+ out.push(`- [${d.status}] ${d.title}${prov(d.provenance)}\n ${d.decision}`);
18
+ }
19
+ if (ctx.bugs.length) {
20
+ out.push(`\n## ๐Ÿž Bug history (don't reintroduce)`);
21
+ for (const b of ctx.bugs)
22
+ out.push(`- [${b.status}/${b.severity}] ${b.title} โ€” root cause: ${b.root_cause}${prov(b.provenance)}`);
23
+ }
24
+ if (ctx.blast_radius.length) {
25
+ out.push(`\n## ๐Ÿ’ฅ Blast radius (transitive dependents)`);
26
+ out.push(ctx.blast_radius.map((d) => `- [d${d.depth}] ${d.via}`).join("\n"));
27
+ }
28
+ if (ctx.components.length)
29
+ out.push(`\n## ๐Ÿ“ฆ Components: ${ctx.components.map((c) => c.name).join(", ")}`);
30
+ if (out.length === 1)
31
+ out.push(`\n(No recorded constraints/decisions/bugs for this target yet โ€” Hunch is still learning it.)`);
32
+ // crude budget trim: ~4 chars/token. Slice on code points (not UTF-16 units)
33
+ // and back off to the last line boundary so we never split a surrogate pair or
34
+ // a record mid-line.
35
+ const text = out.join("\n");
36
+ const cap = ctx.budget_tokens * 4;
37
+ const chars = [...text];
38
+ if (chars.length <= cap)
39
+ return text + "\n";
40
+ let trimmed = chars.slice(0, cap).join("");
41
+ const lastNl = trimmed.lastIndexOf("\n");
42
+ if (lastNl > cap * 0.5)
43
+ trimmed = trimmed.slice(0, lastNl);
44
+ return trimmed + "\nโ€ฆ (trimmed to budget)\n";
45
+ }
46
+ //# sourceMappingURL=format.js.map