@davesheffer/hunch 0.15.4 → 0.16.1
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 +10 -0
- package/dist/cli/index.js +6 -5
- package/dist/core/checkreport.js +14 -1
- package/dist/mcp/server.js +1 -1
- package/dist/store/hunchStore.js +57 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,6 +135,16 @@ forbidden dependency) — code that never existed, so a diff reviewer is blind t
|
|
|
135
135
|
decision carries machine-checkable **tripwires**; re-introduce one and Hunch blocks it with
|
|
136
136
|
the receipt of what you rejected and why. → [docs](https://hunch-pi.vercel.app/docs#veto)
|
|
137
137
|
|
|
138
|
+
### Redundancy Guard — "this already exists"
|
|
139
|
+
|
|
140
|
+
An agent works from a *local* context window, so it re-implements a helper that already
|
|
141
|
+
lives three modules over, or re-adds a dependency the codebase already has — sprawl a
|
|
142
|
+
diff-only reviewer can't see, but Hunch's symbol graph can. Add a function or class already
|
|
143
|
+
defined elsewhere and `hunch check` / the CI guard / `hunch_merge_verdict` flag it with the
|
|
144
|
+
existing location. Deterministic and **advisory** — it never blocks; tuned to stay quiet
|
|
145
|
+
(stopword + length filters, scoped to the change's own project root, move-aware so a
|
|
146
|
+
refactor isn't mistaken for a duplicate). → [docs](https://hunch-pi.vercel.app/docs#redundancy)
|
|
147
|
+
|
|
138
148
|
Plus the **Regression Guard** (re-adding deliberately-retired code) and the
|
|
139
149
|
**[CI Constraint Guard](https://hunch-pi.vercel.app/docs#ci)** (`hunch ci` — a PR gate that
|
|
140
150
|
comments the affected `con_`/`dec_` ids and fails on a blocking one).
|
package/dist/cli/index.js
CHANGED
|
@@ -545,7 +545,7 @@ program
|
|
|
545
545
|
// ---- check (constraint enforcement) ---------------------------------------
|
|
546
546
|
program
|
|
547
547
|
.command("check")
|
|
548
|
-
.description("Flag changes that touch a do-not-break invariant — the local guardrail AND the CI/PR Constraint Guard.")
|
|
548
|
+
.description("Flag changes that touch a do-not-break invariant — the local guardrail AND the CI/PR Constraint Guard. Also flags (advisory) symbols you add that already exist elsewhere — possible re-implementation/sprawl.")
|
|
549
549
|
.option("--staged", "check git staged files (default)")
|
|
550
550
|
.option("--commit <sha>", "check a specific commit's files")
|
|
551
551
|
.option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
|
|
@@ -557,7 +557,7 @@ program
|
|
|
557
557
|
if (sources.length > 1)
|
|
558
558
|
return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
|
|
559
559
|
const markdown = opts.format === "markdown";
|
|
560
|
-
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
|
|
560
|
+
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
|
|
561
561
|
const { store, root } = storeFor();
|
|
562
562
|
// Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
|
|
563
563
|
// branch) — otherwise the diff is empty and the guard passes vacuously.
|
|
@@ -575,8 +575,9 @@ program
|
|
|
575
575
|
return;
|
|
576
576
|
}
|
|
577
577
|
// DIRECT (scope match) + NEAR (blast radius) + REGRESSION (re-added retired
|
|
578
|
-
// code) +
|
|
579
|
-
//
|
|
578
|
+
// code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
|
|
579
|
+
// hardened strict gate + causal `why` citations — all assembled by the shared
|
|
580
|
+
// store.buildCheckReport (also used by the hunch_merge_verdict tool).
|
|
580
581
|
const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
|
|
581
582
|
const report = store.buildCheckReport(files, diff, {
|
|
582
583
|
strict: !!opts.strict,
|
|
@@ -633,7 +634,7 @@ const vetoCmd = program
|
|
|
633
634
|
}
|
|
634
635
|
// Render ONLY the veto class — zero the other sections so the shared renderer
|
|
635
636
|
// shows just the rejected-alternative reversals (the rest is `hunch check`).
|
|
636
|
-
const vetoOnly = { ...full, direct: [], near: [], regressions: [], strictBlockers: 0, regBlocking: 0 };
|
|
637
|
+
const vetoOnly = { ...full, direct: [], near: [], regressions: [], redundant: [], strictBlockers: 0, regBlocking: 0 };
|
|
637
638
|
console.log(markdown ? renderMarkdown(vetoOnly) : renderText(vetoOnly));
|
|
638
639
|
if (reportFailsStrict(vetoOnly))
|
|
639
640
|
process.exitCode = 1;
|
package/dist/core/checkreport.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* then renders it as text (terminal, unchanged) or markdown (a PR comment posted
|
|
5
5
|
* by the GitHub Action). The exit-code decision lives with the caller. */
|
|
6
6
|
export function reportIsClean(r) {
|
|
7
|
-
return r.direct.length === 0 && r.near.length === 0 && r.regressions.length === 0 && r.vetoes.length === 0;
|
|
7
|
+
return r.direct.length === 0 && r.near.length === 0 && r.regressions.length === 0 && r.vetoes.length === 0 && r.redundant.length === 0;
|
|
8
8
|
}
|
|
9
9
|
/** True when --strict should FAIL the commit/PR. */
|
|
10
10
|
export function reportFailsStrict(r) {
|
|
@@ -76,6 +76,12 @@ export function renderText(r) {
|
|
|
76
76
|
out.push(` ${v.blocking ? "⛔" : "⚠"} ${v.decision} rejected this approach${v.blocking ? " (human-confirmed)" : " (advisory)"}\n you rejected: ${clip(v.alternative)}\n you chose: ${clip(v.chosen)}\n evidence: ${v.evidence.slice(0, 4).join(", ")}`);
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
if (r.redundant.length) {
|
|
80
|
+
out.push(`${r.direct.length || r.near.length || r.regressions.length || r.vetoes.length ? "\n" : ""}Possibly re-implements ${r.redundant.length} symbol(s) that already exist (advisory — review, never blocks):\n`);
|
|
81
|
+
for (const x of r.redundant) {
|
|
82
|
+
out.push(` ⟲ adds ${x.kind} \`${x.name}\` — already defined in ${x.existingFile}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
79
85
|
if (reportFailsStrict(r)) {
|
|
80
86
|
const reasons = [
|
|
81
87
|
r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
|
|
@@ -142,6 +148,13 @@ export function renderMarkdown(r) {
|
|
|
142
148
|
}
|
|
143
149
|
out.push("");
|
|
144
150
|
}
|
|
151
|
+
if (r.redundant.length) {
|
|
152
|
+
out.push(`### ⟲ Possibly re-implements existing code (advisory)`);
|
|
153
|
+
for (const x of r.redundant) {
|
|
154
|
+
out.push(`- \`${x.name}\` (${x.kind}) — already defined in \`${x.existingFile}\``);
|
|
155
|
+
}
|
|
156
|
+
out.push("");
|
|
157
|
+
}
|
|
145
158
|
out.push("---");
|
|
146
159
|
if (reportFailsStrict(r)) {
|
|
147
160
|
const reasons = [
|
package/dist/mcp/server.js
CHANGED
|
@@ -323,7 +323,7 @@ export function buildServer(root) {
|
|
|
323
323
|
// -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
|
|
324
324
|
server.registerTool("hunch_merge_verdict", {
|
|
325
325
|
title: "Causal merge verdict: is this change safe against the recorded WHY?",
|
|
326
|
-
description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory)
|
|
326
|
+
description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory), any deliberately-retired code the diff re-introduces, and symbols the diff adds that are already defined elsewhere in the graph (possible re-implementation/sprawl, advisory). Deterministic, no LLM. Omit base AND commit to check STAGED changes; pass base (e.g. origin/main) for a PR range, or commit for a single commit. Call this before merging a widely-scoped change.",
|
|
327
327
|
inputSchema: {
|
|
328
328
|
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
329
329
|
commit: z.string().optional().describe("Diff a single commit (sha/ref). Omit base AND commit to check staged changes."),
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -443,6 +443,10 @@ export class HunchStore {
|
|
|
443
443
|
const staleIds = new Set(staleRecords.filter((s) => s.kind === "constraint").map((s) => s.id));
|
|
444
444
|
const staleDecisionIds = new Set(staleRecords.filter((s) => s.kind === "decision").map((s) => s.id));
|
|
445
445
|
const vetoes = this.vetoHits(an, files, staleDecisionIds);
|
|
446
|
+
const redundant = this.redundantSymbols(an.addedSymbols, files, {
|
|
447
|
+
movedFrom: [...an.filesRenamed.map((r) => r.from), ...an.filesDeleted],
|
|
448
|
+
removedNames: new Set(an.removedSymbols.map((s) => s.name)),
|
|
449
|
+
});
|
|
446
450
|
const directReport = [...direct.values()].map(({ c, files: fs }) => {
|
|
447
451
|
const stale = staleIds.has(c.id);
|
|
448
452
|
const strictBlocks = isStrictBlocker(c, stale);
|
|
@@ -460,11 +464,64 @@ export class HunchStore {
|
|
|
460
464
|
near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
|
|
461
465
|
regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
|
|
462
466
|
vetoes: vetoes.map((v) => ({ decision: v.decision, title: v.title, alternative: v.alternative, chosen: v.chosen, tier: v.tier, evidence: v.evidence, blocking: v.blocks })),
|
|
467
|
+
redundant,
|
|
463
468
|
strictBlockers: directReport.filter((d) => d.strictBlocks).length,
|
|
464
469
|
regBlocking: regHits.filter((h) => h.blocking).length,
|
|
465
470
|
vetoBlocking: vetoes.filter((v) => v.blocks).length,
|
|
466
471
|
};
|
|
467
472
|
}
|
|
473
|
+
/** Sprawl/"this already exists" guard (ADVISORY, never blocks). A symbol the diff
|
|
474
|
+
* ADDS whose name already exists in the indexed graph in a file NOT touched by the
|
|
475
|
+
* diff is a likely re-implementation the agent's local context window couldn't see.
|
|
476
|
+
* Read-only. Heuristic, so noise is controlled: top-level function/class/const only,
|
|
477
|
+
* name length ≥ 4, a stopword list of generic names, deduped, and capped. */
|
|
478
|
+
redundantSymbols(added, files, opts = {}) {
|
|
479
|
+
if (!added.length)
|
|
480
|
+
return [];
|
|
481
|
+
const KINDS = new Set(["function", "class", "const"]);
|
|
482
|
+
const STOP = new Set([
|
|
483
|
+
"index", "handler", "handlers", "default", "main", "run", "start", "stop", "setup", "init",
|
|
484
|
+
"constructor", "render", "create", "update", "build", "parse", "load", "save", "next", "data",
|
|
485
|
+
"value", "item", "items", "props", "state", "config", "options", "result", "route", "routes",
|
|
486
|
+
"app", "server", "client", "test", "tests", "mock", "stub", "helper", "helpers", "util", "utils",
|
|
487
|
+
"types", "schema", "constants", "common", "shared", "base", "model", "models", "view", "store",
|
|
488
|
+
]);
|
|
489
|
+
// Match only against top-level VALUE declarations already in the graph. A method
|
|
490
|
+
// (`obj.close()`) or the file node itself sharing a name is not a re-implementation.
|
|
491
|
+
// ("variable" is kept for forward-compat; the current indexer emits arrow-fn consts
|
|
492
|
+
// as "function", so const re-implementations are still matched.)
|
|
493
|
+
const EXISTING_KINDS = new Set(["function", "class", "variable"]);
|
|
494
|
+
// A symbol carried into a moved/deleted file is being relocated, not duplicated. The
|
|
495
|
+
// changed-file list uses --diff-filter=ACMR, so a sub-threshold move (Add new + Delete
|
|
496
|
+
// old) drops the old path — add the move-from / deleted paths back so their lingering
|
|
497
|
+
// graph entries are not mistaken for a separate "existing" implementation.
|
|
498
|
+
const changed = new Set(files);
|
|
499
|
+
for (const p of opts.movedFrom ?? [])
|
|
500
|
+
changed.add(p);
|
|
501
|
+
const removedNames = opts.removedNames ?? new Set();
|
|
502
|
+
// Only compare within the diff's own top-level root(s). A name that also exists in a
|
|
503
|
+
// test fixture or a separate sub-project (test/, vscode-extension/, site/) is not
|
|
504
|
+
// sprawl in the source under change — different roots, different ownership.
|
|
505
|
+
const roots = new Set(files.map((f) => f.split("/")[0]));
|
|
506
|
+
const symbols = this.json.loadAll("symbols");
|
|
507
|
+
const out = [];
|
|
508
|
+
const seen = new Set();
|
|
509
|
+
for (const sc of added) {
|
|
510
|
+
const name = sc.name;
|
|
511
|
+
if (seen.has(name) || name.length < 4 || !KINDS.has(sc.kind) || STOP.has(name.toLowerCase()))
|
|
512
|
+
continue;
|
|
513
|
+
if (removedNames.has(name))
|
|
514
|
+
continue; // the same name was removed in this diff → moved, not duplicated
|
|
515
|
+
const hit = symbols.find((s) => s.name === name && !changed.has(s.file) && EXISTING_KINDS.has(s.kind) && roots.has(s.file.split("/")[0]));
|
|
516
|
+
if (hit) {
|
|
517
|
+
seen.add(name);
|
|
518
|
+
out.push({ name, kind: sc.kind, existingFile: hit.file });
|
|
519
|
+
if (out.length >= 10)
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
468
525
|
/** Time-travel: the decision history for a target — every decision touching it,
|
|
469
526
|
* newest-first, with its valid-time window and supersession links. Answers
|
|
470
527
|
* "what did we believe, and when/why did it change?" (hunch_timeline). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
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.",
|