@davesheffer/hunch 0.27.0 → 0.29.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 +94 -1
- package/dist/core/compare.js +35 -0
- package/dist/extractors/comments.js +75 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/mcp/server.js +28 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -29,7 +29,8 @@ import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesi
|
|
|
29
29
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
30
30
|
import { selectProvider } from "../synthesis/provider.js";
|
|
31
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
|
+
import { runbookId, decisionId } from "../core/ids.js";
|
|
33
|
+
import { extractInlineIntent } from "../extractors/comments.js";
|
|
33
34
|
import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
|
|
34
35
|
import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
35
36
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
@@ -45,6 +46,7 @@ import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/co
|
|
|
45
46
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
46
47
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
47
48
|
import { computeDrift } from "../core/drift.js";
|
|
49
|
+
import { compareCandidates } from "../core/compare.js";
|
|
48
50
|
import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
|
|
49
51
|
import { constraintId } from "../core/ids.js";
|
|
50
52
|
import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
|
|
@@ -535,6 +537,97 @@ program
|
|
|
535
537
|
console.log(dim(" advisory, deterministic draft — refine the steps/gotchas; surfaced via `hunch query` and MCP."));
|
|
536
538
|
store.close();
|
|
537
539
|
});
|
|
540
|
+
// ---- capture-comments (inline intent → graph; addendum #2) ----------------
|
|
541
|
+
program
|
|
542
|
+
.command("capture-comments")
|
|
543
|
+
.description("Capture inline intent comments into the graph: `hunch-why:` → a decision, `hunch-rule:` → a file-scoped constraint. Deterministic + idempotent.")
|
|
544
|
+
.option("--private", "write captured records into the private overlay (HUNCH_PRIVATE_DIR)")
|
|
545
|
+
.action((opts) => {
|
|
546
|
+
const { store, root } = storeFor();
|
|
547
|
+
if (opts.private && !store.hasPrivate) {
|
|
548
|
+
store.close();
|
|
549
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
550
|
+
}
|
|
551
|
+
const intents = extractInlineIntent(root);
|
|
552
|
+
const now = new Date().toISOString();
|
|
553
|
+
let dec = 0, con = 0;
|
|
554
|
+
for (const it of intents) {
|
|
555
|
+
const ev = [`${it.file}:${it.line}`];
|
|
556
|
+
if (it.kind === "why") {
|
|
557
|
+
const id = decisionId(`inline:${it.file}:${it.text}`);
|
|
558
|
+
const prev = store.recs("decisions").find((d) => d.id === id); // preserve window for idempotent re-capture
|
|
559
|
+
const rec = {
|
|
560
|
+
id, title: it.text, status: "accepted",
|
|
561
|
+
context: `Captured from an inline hunch-why comment (${it.file}:${it.line}).`,
|
|
562
|
+
decision: it.text, consequences: [], alternatives_rejected: [], rejected_tripwires: [],
|
|
563
|
+
related_components: [], related_files: [it.file], supersedes: null, superseded_by: null,
|
|
564
|
+
caused_by_bug: null, commit: null, valid_from: prev?.valid_from ?? now, valid_to: null,
|
|
565
|
+
retired: { symbols: [], deps: [] },
|
|
566
|
+
provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev }, date: prev?.date ?? now,
|
|
567
|
+
};
|
|
568
|
+
if (opts.private)
|
|
569
|
+
store.putPrivate("decisions", rec);
|
|
570
|
+
else
|
|
571
|
+
store.json.put("decisions", rec);
|
|
572
|
+
dec++;
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
const id = constraintId(`inline:${it.file}:${it.text}`);
|
|
576
|
+
const prev = store.recs("constraints").find((c) => c.id === id);
|
|
577
|
+
const rec = {
|
|
578
|
+
id, type: "correctness", statement: it.text, scope: [it.file],
|
|
579
|
+
// Advisory by default — an inline rule never auto-blocks a build; raise severity
|
|
580
|
+
// deliberately if you want enforcement. Keeps day-one zero false-positive rage.
|
|
581
|
+
severity: "warning", enforcement: "advisory_v1",
|
|
582
|
+
rationale: `Captured from an inline hunch-rule comment (${it.file}:${it.line}).`,
|
|
583
|
+
source_decision: null, violations: [], status: "active",
|
|
584
|
+
valid_from: prev?.valid_from ?? now, valid_to: null,
|
|
585
|
+
provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev },
|
|
586
|
+
};
|
|
587
|
+
if (opts.private)
|
|
588
|
+
store.putPrivate("constraints", rec);
|
|
589
|
+
else
|
|
590
|
+
store.json.put("constraints", rec);
|
|
591
|
+
con++;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
store.reindex();
|
|
595
|
+
if (!intents.length)
|
|
596
|
+
console.log("No `hunch-why:` / `hunch-rule:` comments found.");
|
|
597
|
+
else
|
|
598
|
+
console.log(`✓ captured ${dec} decision(s) + ${con} constraint(s) from inline comments${opts.private ? " [private overlay]" : ""}`);
|
|
599
|
+
store.close();
|
|
600
|
+
});
|
|
601
|
+
// ---- compare (rank N candidate solutions by architectural fit) ------------
|
|
602
|
+
program
|
|
603
|
+
.command("compare")
|
|
604
|
+
.description("Rank candidate branches/commits by how well each fits the architecture — deterministic merge-verdict over the graph (the 'evaluate 5 solutions' check).")
|
|
605
|
+
.argument("<candidates...>", "refs to compare (branches or commits), e.g. feat-a feat-b feat-c")
|
|
606
|
+
.option("--base <ref>", "base to diff each candidate against (3-dot, since merge-base)", "main")
|
|
607
|
+
.action((candidates, opts) => {
|
|
608
|
+
const { store, root } = storeFor();
|
|
609
|
+
if (!isGitRepo(root)) {
|
|
610
|
+
store.close();
|
|
611
|
+
return fail("compare needs a git repo");
|
|
612
|
+
}
|
|
613
|
+
if (!revExists(opts.base, root)) {
|
|
614
|
+
store.close();
|
|
615
|
+
return fail(`base ref "${opts.base}" not found (pass --base <ref>)`);
|
|
616
|
+
}
|
|
617
|
+
store.reindex();
|
|
618
|
+
const ranked = compareCandidates(store, root, opts.base, candidates);
|
|
619
|
+
const icon = (v) => (v === "pass" ? "✅" : v === "warn" ? "⚠️ " : "⛔");
|
|
620
|
+
console.log(`Candidates vs ${opts.base} — best architectural fit first:\n`);
|
|
621
|
+
ranked.forEach((c, i) => {
|
|
622
|
+
if (c.error) {
|
|
623
|
+
console.log(` ${i + 1}. ${c.ref} — ${c.error}`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const best = i === 0 ? dim(" ← best fit") : "";
|
|
627
|
+
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}`);
|
|
628
|
+
});
|
|
629
|
+
store.close();
|
|
630
|
+
});
|
|
538
631
|
// ---- embed (opt-in semantic search) ---------------------------------------
|
|
539
632
|
program
|
|
540
633
|
.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
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline intent capture (roadmap addendum #2). A developer marks intent right where
|
|
3
|
+
* it lives in the code — `hunch-why: <reason>` (in a comment → a Decision) or
|
|
4
|
+
* `hunch-rule: <invariant>` (→ a file-scoped Constraint) — and Hunch lifts it into
|
|
5
|
+
* the graph, deterministically. The third capture source alongside commit synthesis and
|
|
6
|
+
* correction capture. The tag must follow a comment marker (the slash pair, #, *, --,
|
|
7
|
+
* <!--, ;) so a matching STRING literal in code isn't mistaken for intent. (Line-based,
|
|
8
|
+
* so a tagged line that is itself a string literal can still false-positive — advisory.)
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { trackedFiles } from "./git.js";
|
|
13
|
+
import { toPosixTarget } from "../core/paths.js";
|
|
14
|
+
const EXTS = [
|
|
15
|
+
".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs",
|
|
16
|
+
".py", ".go", ".rb", ".java", ".rs", ".php", ".cs", ".kt", ".swift", ".scala",
|
|
17
|
+
".c", ".h", ".cc", ".cpp", ".hpp", ".sql", ".sh",
|
|
18
|
+
];
|
|
19
|
+
const SKIP = new Set(["node_modules", ".git", ".hunch", ".hunch-private", "dist", "build", "out", "vendor", ".next"]);
|
|
20
|
+
const TAG = /(?:\/\/|#|\*|--|<!--|;)\s*hunch-(why|rule)\s*:\s*(.+?)\s*(?:\*\/|-->|$)/i;
|
|
21
|
+
/** Tracked source files (git ls-files); falls back to a bounded walk outside git. */
|
|
22
|
+
function sourceFiles(root) {
|
|
23
|
+
const tracked = trackedFiles(root, EXTS);
|
|
24
|
+
if (tracked.length)
|
|
25
|
+
return tracked;
|
|
26
|
+
const out = [];
|
|
27
|
+
const walk = (dir, rel, depth) => {
|
|
28
|
+
if (depth > 8)
|
|
29
|
+
return;
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
if (e.name.startsWith("."))
|
|
39
|
+
continue;
|
|
40
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
41
|
+
if (e.isDirectory()) {
|
|
42
|
+
if (!SKIP.has(e.name))
|
|
43
|
+
walk(join(dir, e.name), r, depth + 1);
|
|
44
|
+
}
|
|
45
|
+
else if (EXTS.some((x) => e.name.endsWith(x))) {
|
|
46
|
+
out.push(r);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(root, "", 0);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
export function extractInlineIntent(root) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const rel of sourceFiles(root)) {
|
|
56
|
+
let content;
|
|
57
|
+
try {
|
|
58
|
+
content = readFileSync(join(root, rel), "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!content.includes("hunch-"))
|
|
64
|
+
continue; // cheap skip before the per-line scan
|
|
65
|
+
const file = toPosixTarget(rel);
|
|
66
|
+
const lines = content.split("\n");
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const m = TAG.exec(lines[i]);
|
|
69
|
+
if (m)
|
|
70
|
+
out.push({ kind: m[1].toLowerCase(), text: m[2].trim(), file, line: i + 1 });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=comments.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("");
|
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.29.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.",
|