@davesheffer/hunch 0.26.1 → 0.28.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 +45 -6
- package/dist/core/compare.js +35 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/providers.js +27 -0
- package/dist/mcp/server.js +28 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -38,13 +38,14 @@ import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integr
|
|
|
38
38
|
import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
39
39
|
import { updateClaudeMd } from "../integrations/claudemd.js";
|
|
40
40
|
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
41
|
-
import { scaffoldProviders, regenerateGrounding } from "../integrations/providers.js";
|
|
41
|
+
import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding } from "../integrations/providers.js";
|
|
42
42
|
import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
|
|
43
43
|
import { formatContext } from "../core/format.js";
|
|
44
44
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
45
45
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
46
46
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
47
47
|
import { computeDrift } from "../core/drift.js";
|
|
48
|
+
import { compareCandidates } from "../core/compare.js";
|
|
48
49
|
import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
|
|
49
50
|
import { constraintId } from "../core/ids.js";
|
|
50
51
|
import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
|
|
@@ -165,9 +166,13 @@ program
|
|
|
165
166
|
ensureGitignore(root); // keep the derived SQLite index out of git (idempotent)
|
|
166
167
|
const res = indexRepo(store, root);
|
|
167
168
|
const { counts } = store.reindex();
|
|
168
|
-
|
|
169
|
+
// Self-heal grounding: pick up generator fixes (param names) + fresh counts in every
|
|
170
|
+
// assistant doc the project already has — no manual re-init. Refresh-only (no scaffold).
|
|
171
|
+
const healed = refreshExistingGrounding(root, store);
|
|
169
172
|
console.log(`Indexed ${res.files} files:`);
|
|
170
173
|
console.log(` ${counts.symbols} symbols, ${counts.edges} edges, ${counts.components} components`);
|
|
174
|
+
if (healed.length)
|
|
175
|
+
console.log(` grounding refreshed: ${healed.join(", ")}`);
|
|
171
176
|
if (res.skipped)
|
|
172
177
|
console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
|
|
173
178
|
store.close();
|
|
@@ -262,10 +267,14 @@ program
|
|
|
262
267
|
const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: opts.private, deep: opts.deep, verify: opts.verify, samples: parseSamples(opts.samples) });
|
|
263
268
|
if (r.status === "written") {
|
|
264
269
|
store.reindex();
|
|
265
|
-
// Don't rewrite
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
270
|
+
// Don't rewrite grounding from the hook — it would dirty the working tree on
|
|
271
|
+
// every commit. Off the hook (manual `hunch sync`), self-heal ALL existing
|
|
272
|
+
// grounding docs (param-name fixes + fresh counts), not just CLAUDE.md.
|
|
273
|
+
if (!opts.fromHook) {
|
|
274
|
+
const healed = refreshExistingGrounding(root, store);
|
|
275
|
+
if (healed.length && !opts.quiet)
|
|
276
|
+
console.log(` ↳ grounding refreshed: ${healed.join(", ")}`);
|
|
277
|
+
}
|
|
269
278
|
// Opt-in: persist the captured decision in the repo it landed in (private store
|
|
270
279
|
// under --private, else this repo). Best-effort — a non-repo dir / offline push
|
|
271
280
|
// just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
|
|
@@ -527,6 +536,36 @@ program
|
|
|
527
536
|
console.log(dim(" advisory, deterministic draft — refine the steps/gotchas; surfaced via `hunch query` and MCP."));
|
|
528
537
|
store.close();
|
|
529
538
|
});
|
|
539
|
+
// ---- compare (rank N candidate solutions by architectural fit) ------------
|
|
540
|
+
program
|
|
541
|
+
.command("compare")
|
|
542
|
+
.description("Rank candidate branches/commits by how well each fits the architecture — deterministic merge-verdict over the graph (the 'evaluate 5 solutions' check).")
|
|
543
|
+
.argument("<candidates...>", "refs to compare (branches or commits), e.g. feat-a feat-b feat-c")
|
|
544
|
+
.option("--base <ref>", "base to diff each candidate against (3-dot, since merge-base)", "main")
|
|
545
|
+
.action((candidates, opts) => {
|
|
546
|
+
const { store, root } = storeFor();
|
|
547
|
+
if (!isGitRepo(root)) {
|
|
548
|
+
store.close();
|
|
549
|
+
return fail("compare needs a git repo");
|
|
550
|
+
}
|
|
551
|
+
if (!revExists(opts.base, root)) {
|
|
552
|
+
store.close();
|
|
553
|
+
return fail(`base ref "${opts.base}" not found (pass --base <ref>)`);
|
|
554
|
+
}
|
|
555
|
+
store.reindex();
|
|
556
|
+
const ranked = compareCandidates(store, root, opts.base, candidates);
|
|
557
|
+
const icon = (v) => (v === "pass" ? "✅" : v === "warn" ? "⚠️ " : "⛔");
|
|
558
|
+
console.log(`Candidates vs ${opts.base} — best architectural fit first:\n`);
|
|
559
|
+
ranked.forEach((c, i) => {
|
|
560
|
+
if (c.error) {
|
|
561
|
+
console.log(` ${i + 1}. ${c.ref} — ${c.error}`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const best = i === 0 ? dim(" ← best fit") : "";
|
|
565
|
+
console.log(` ${i + 1}. ${icon(c.verdict)} ${c.ref} [${c.verdict}] ${c.blocking} blocking · ${c.direct} direct · ${c.near} near · ${c.vetoes} veto · ${c.redundant} redundant (${c.files} files)${best}`);
|
|
566
|
+
});
|
|
567
|
+
store.close();
|
|
568
|
+
});
|
|
530
569
|
// ---- embed (opt-in semantic search) ---------------------------------------
|
|
531
570
|
program
|
|
532
571
|
.command("embed")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { verdict } from "./checkreport.js";
|
|
2
|
+
import { revExists, rangeFiles, rangeDiff } from "../extractors/git.js";
|
|
3
|
+
/** A lower fit score is better. Verdict dominates (pass < warn < block), then the
|
|
4
|
+
* blocking count, then total advisory hits. Errored candidates sort last. */
|
|
5
|
+
function fitKey(c) {
|
|
6
|
+
const tier = c.error ? 9 : c.verdict === "pass" ? 0 : c.verdict === "warn" ? 1 : 2;
|
|
7
|
+
return [tier, c.blocking, c.direct + c.near + c.vetoes + c.redundant];
|
|
8
|
+
}
|
|
9
|
+
export function compareCandidates(store, root, base, candidates) {
|
|
10
|
+
const results = candidates.map((ref) => {
|
|
11
|
+
const zero = { ref, verdict: "block", blocking: 0, direct: 0, near: 0, vetoes: 0, redundant: 0, files: 0 };
|
|
12
|
+
if (!revExists(ref, root))
|
|
13
|
+
return { ...zero, error: `ref "${ref}" not found` };
|
|
14
|
+
const files = rangeFiles(base, root, ref);
|
|
15
|
+
if (!files.length)
|
|
16
|
+
return { ...zero, verdict: "pass", error: `no changes vs ${base}` };
|
|
17
|
+
const r = store.buildCheckReport(files, rangeDiff(base, root, ref), { strict: true });
|
|
18
|
+
return {
|
|
19
|
+
ref,
|
|
20
|
+
verdict: verdict(r),
|
|
21
|
+
blocking: r.strictBlockers + r.regBlocking + r.vetoBlocking,
|
|
22
|
+
direct: r.direct.length,
|
|
23
|
+
near: r.near.length,
|
|
24
|
+
vetoes: r.vetoes.length,
|
|
25
|
+
redundant: r.redundant.length,
|
|
26
|
+
files: files.length,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
return results.sort((a, b) => {
|
|
30
|
+
const [at, ab, ah] = fitKey(a);
|
|
31
|
+
const [bt, bb, bh] = fitKey(b);
|
|
32
|
+
return at - bt || ab - bb || ah - bh;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=compare.js.map
|
|
@@ -33,6 +33,7 @@ export function renderHunchSection(store) {
|
|
|
33
33
|
lines.push("- `hunch_bug_lineage(symptom_or_symbol)` — has this bug happened before? what was the root cause?");
|
|
34
34
|
lines.push("- `hunch_query(query)` — free-text search across all of Hunch.");
|
|
35
35
|
lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task (e.g. \"add an MCP tool\", \"cut a release\").");
|
|
36
|
+
lines.push("- `hunch_compare(candidates)` — rank N candidate branches/commits by architectural fit (fewest invariant hits).");
|
|
36
37
|
lines.push("- `hunch_record_decision(...)` — write back a decision after a non-trivial choice.");
|
|
37
38
|
if (constraints.length) {
|
|
38
39
|
lines.push("");
|
|
@@ -241,6 +241,33 @@ export function regenerateGrounding(root, store) {
|
|
|
241
241
|
writeWindsurfRule(root, store),
|
|
242
242
|
];
|
|
243
243
|
}
|
|
244
|
+
/** Self-heal: refresh the Hunch section in each grounding doc that ALREADY exists,
|
|
245
|
+
* and report which ones actually changed. Unlike regenerateGrounding it NEVER creates
|
|
246
|
+
* a file (so it can't scaffold grounding into a project that opted out of an
|
|
247
|
+
* assistant). Run by `hunch index` and non-hook `hunch sync` so a project silently
|
|
248
|
+
* picks up generator fixes (e.g. corrected MCP tool param names) and fresh record
|
|
249
|
+
* counts on the next refresh — no manual `hunch init`. Not run from the commit hook,
|
|
250
|
+
* which deliberately avoids dirtying the working tree on every commit. */
|
|
251
|
+
export function refreshExistingGrounding(root, store) {
|
|
252
|
+
const targets = [
|
|
253
|
+
["CLAUDE.md", () => updateClaudeMd(root, store)],
|
|
254
|
+
["AGENTS.md", () => writeAgentsMd(root, store)],
|
|
255
|
+
[join(".github", "copilot-instructions.md"), () => writeCopilotInstructions(root, store)],
|
|
256
|
+
[join(".cursor", "rules", "hunch.mdc"), () => writeCursorRule(root, store)],
|
|
257
|
+
[join(".windsurf", "rules", "hunch.md"), () => writeWindsurfRule(root, store)],
|
|
258
|
+
];
|
|
259
|
+
const changed = [];
|
|
260
|
+
for (const [rel, write] of targets) {
|
|
261
|
+
const file = join(root, rel);
|
|
262
|
+
if (!existsSync(file))
|
|
263
|
+
continue; // refresh-only: never scaffold a doc the project doesn't have
|
|
264
|
+
const before = readFileSync(file, "utf8");
|
|
265
|
+
write();
|
|
266
|
+
if (readFileSync(file, "utf8") !== before)
|
|
267
|
+
changed.push(rel);
|
|
268
|
+
}
|
|
269
|
+
return changed;
|
|
270
|
+
}
|
|
244
271
|
/** Scaffold MCP config + grounding for all supported assistants. Returns a
|
|
245
272
|
* per-assistant summary for `hunch init` to print. Each assistant is isolated:
|
|
246
273
|
* a writer that refuses to clobber a malformed file degrades to a warning rather
|
package/dist/mcp/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { decisionId } from "../core/ids.js";
|
|
|
16
16
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
17
17
|
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, commitAndPushHunch } from "../extractors/git.js";
|
|
18
18
|
import { formatContext } from "../core/format.js";
|
|
19
|
+
import { compareCandidates } from "../core/compare.js";
|
|
19
20
|
import { renderMarkdown, verdict } from "../core/checkreport.js";
|
|
20
21
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
21
22
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
@@ -407,6 +408,33 @@ export function buildServer(root) {
|
|
|
407
408
|
return err(`Failed to compute merge verdict: ${e.message}`);
|
|
408
409
|
}
|
|
409
410
|
});
|
|
411
|
+
// -- hunch_compare --------------------------------------------------------
|
|
412
|
+
server.registerTool("hunch_compare", {
|
|
413
|
+
title: "Rank candidate solutions by architectural fit",
|
|
414
|
+
description: "Given several candidate branches/commits (e.g. N solutions to one task), replay each against engineering memory and RANK them best-fit first — the candidate that trips the fewest in-force invariants, reverses no decisions, and adds the least sprawl wins. Deterministic (the same merge-verdict per candidate, no LLM). Use to choose among multiple solutions before committing to one.",
|
|
415
|
+
inputSchema: {
|
|
416
|
+
candidates: z.array(z.string()).describe("Refs to compare — branches or commits, e.g. ['feat-a','feat-b','feat-c']."),
|
|
417
|
+
base: z.string().optional().describe("Base to diff each candidate against (3-dot; default: main)."),
|
|
418
|
+
},
|
|
419
|
+
}, async ({ candidates, base }) => {
|
|
420
|
+
try {
|
|
421
|
+
const b = base ?? "main";
|
|
422
|
+
if (!candidates.length)
|
|
423
|
+
return err("Pass at least one candidate ref.");
|
|
424
|
+
if (!revExists(b, root))
|
|
425
|
+
return err(`base ref "${b}" does not resolve (in CI, fetch it first).`);
|
|
426
|
+
const ranked = compareCandidates(store, root, b, candidates);
|
|
427
|
+
const icon = (v) => (v === "pass" ? "✅" : v === "warn" ? "⚠" : "⛔");
|
|
428
|
+
const lines = ranked.map((c, i) => c.error
|
|
429
|
+
? `${i + 1}. ${c.ref} — ${c.error}`
|
|
430
|
+
: `${i + 1}. ${icon(c.verdict)} ${c.ref} [${c.verdict}] — ${c.blocking} blocking · ${c.direct} direct · ${c.near} near · ${c.vetoes} veto · ${c.redundant} redundant (${c.files} files)`);
|
|
431
|
+
const best = ranked.find((c) => !c.error);
|
|
432
|
+
return ok(`Candidates vs ${b}, best architectural fit first:\n\n${lines.join("\n")}${best ? `\n\nBest fit: ${best.ref}` : ""}`);
|
|
433
|
+
}
|
|
434
|
+
catch (e) {
|
|
435
|
+
return err(`Failed to compare candidates: ${e.message}`);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
410
438
|
return server;
|
|
411
439
|
}
|
|
412
440
|
function provLine(record) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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.",
|