@davesheffer/hunch 0.10.2 → 0.11.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/dist/cli/index.js +48 -16
- package/dist/core/strictgate.js +24 -0
- package/dist/integrations/hooks.js +3 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { parseTestReport } from "../extractors/testreport.js";
|
|
|
26
26
|
import { selectProvider } from "../synthesis/provider.js";
|
|
27
27
|
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff } from "../extractors/git.js";
|
|
28
28
|
import { analyzeDiff } from "../extractors/diff.js";
|
|
29
|
+
import { isStrictBlocker } from "../core/strictgate.js";
|
|
29
30
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
30
31
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
31
32
|
import { updateClaudeMd } from "../integrations/claudemd.js";
|
|
@@ -54,7 +55,7 @@ program
|
|
|
54
55
|
.description("Scaffold .hunch/, index the repo, install the git hook, and wire up Claude Code.")
|
|
55
56
|
.option("--no-index", "skip the initial repo index")
|
|
56
57
|
.option("--no-enforce", "do not install the advisory pre-commit constraint guard")
|
|
57
|
-
.option("--enforce-strict", "make the pre-commit guard FAIL the commit on a
|
|
58
|
+
.option("--enforce-strict", "make the pre-commit guard FAIL the commit on a direct, high-confidence, non-stale blocking invariant")
|
|
58
59
|
.option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
|
|
59
60
|
.option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
|
|
60
61
|
.option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
|
|
@@ -90,7 +91,7 @@ program
|
|
|
90
91
|
if (opts.enforce !== false || opts.enforceStrict) {
|
|
91
92
|
const strict = !!opts.enforceStrict;
|
|
92
93
|
const p = installPreCommitHook(root, inv.shell, strict);
|
|
93
|
-
console.log(` ✓ pre-commit constraint guard ${p.action} (${strict ? "strict —
|
|
94
|
+
console.log(` ✓ pre-commit constraint guard ${p.action} (${strict ? "strict — fails only on direct, high-confidence, non-stale blocking invariants" : "advisory — flags invariants in scope or blast radius"})`);
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
else {
|
|
@@ -143,20 +144,39 @@ program
|
|
|
143
144
|
store.close();
|
|
144
145
|
});
|
|
145
146
|
// ---- backfill -------------------------------------------------------------
|
|
147
|
+
/** Run an async fn over items with at most `limit` in flight. A fixed pool of
|
|
148
|
+
* workers pulls from a shared cursor — no per-batch barrier, so a slow item
|
|
149
|
+
* never stalls the others. Used by backfill to overlap per-commit LLM spawns. */
|
|
150
|
+
async function mapPool(items, limit, fn) {
|
|
151
|
+
let next = 0;
|
|
152
|
+
const worker = async () => {
|
|
153
|
+
while (next < items.length) {
|
|
154
|
+
const i = next++;
|
|
155
|
+
await fn(items[i], i);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
159
|
+
}
|
|
146
160
|
program
|
|
147
161
|
.command("backfill")
|
|
148
162
|
.description("Replay git history to seed decisions (cold-start fix).")
|
|
149
163
|
.option("--since <spec>", "how far back, e.g. 90d", "90d")
|
|
150
164
|
.option("--max <n>", "max commits to process", "40")
|
|
165
|
+
.option("--concurrency <n>", "commits to synthesize in parallel (the LLM call is the bottleneck)", "4")
|
|
151
166
|
.action(async (opts) => {
|
|
152
167
|
const { store, root } = storeFor();
|
|
153
168
|
if (!isGitRepo(root))
|
|
154
169
|
return fail("backfill needs a git repo");
|
|
155
170
|
store.json.ensureDirs();
|
|
156
171
|
const commits = logSince(opts.since, root, Number(opts.max));
|
|
157
|
-
|
|
172
|
+
const conc = Math.max(1, Math.min(16, Number(opts.concurrency) || 4));
|
|
173
|
+
console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since} (concurrency ${conc})…`);
|
|
158
174
|
let written = 0, skipped = 0, llm = 0, heuristic = 0;
|
|
159
|
-
|
|
175
|
+
// The per-commit cost is the Claude synthesis spawn; run several at once. Safe:
|
|
176
|
+
// each commit drafts independently and writes its OWN decision file atomically,
|
|
177
|
+
// and the store's JS-side reads/writes run synchronously between awaits (single
|
|
178
|
+
// thread) — only the LLM spawns overlap. reindex() runs once, after the pool.
|
|
179
|
+
await mapPool(commits, conc, async (sha) => {
|
|
160
180
|
const r = await syncCommit(store, root, sha);
|
|
161
181
|
if (r.status === "written") {
|
|
162
182
|
written++;
|
|
@@ -168,7 +188,7 @@ program
|
|
|
168
188
|
}
|
|
169
189
|
else
|
|
170
190
|
skipped++;
|
|
171
|
-
}
|
|
191
|
+
});
|
|
172
192
|
store.reindex();
|
|
173
193
|
updateClaudeMd(root, store);
|
|
174
194
|
// Honest tally of where the tokens went: trivial commits are seeded by the
|
|
@@ -509,7 +529,7 @@ program
|
|
|
509
529
|
.description("Flag changes that touch a do-not-break invariant's scope (guardrail).")
|
|
510
530
|
.option("--staged", "check git staged files (default)")
|
|
511
531
|
.option("--commit <sha>", "check a specific commit's files")
|
|
512
|
-
.option("--strict", "exit non-zero
|
|
532
|
+
.option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
|
|
513
533
|
.option("--blast", "also print the dependency blast radius of the changed files")
|
|
514
534
|
.action((opts) => {
|
|
515
535
|
if (opts.commit && opts.staged)
|
|
@@ -567,20 +587,29 @@ program
|
|
|
567
587
|
store.close();
|
|
568
588
|
return;
|
|
569
589
|
}
|
|
570
|
-
|
|
590
|
+
// --strict may FAIL a commit ONLY on a DIRECT, high-confidence, non-stale
|
|
591
|
+
// blocking invariant (see strictgate.ts) — never on a blast-radius ("near")
|
|
592
|
+
// guess or a stale/low-confidence record. Those weaker hits still print, as
|
|
593
|
+
// advisory, so strict mode is safe to enable on a shared repo.
|
|
594
|
+
const staleConstraintIds = opts.strict
|
|
595
|
+
? new Set(store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint").map((s) => s.id))
|
|
596
|
+
: new Set();
|
|
597
|
+
let strictBlockers = 0;
|
|
571
598
|
if (direct.size) {
|
|
572
599
|
console.log(`Directly touches ${direct.size} invariant(s):\n`);
|
|
573
600
|
for (const { c, files: fs } of direct.values()) {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
601
|
+
const blocks = isStrictBlocker(c, staleConstraintIds.has(c.id));
|
|
602
|
+
if (blocks)
|
|
603
|
+
strictBlockers++;
|
|
604
|
+
const note = opts.strict && c.severity === "blocking" && !blocks
|
|
605
|
+
? staleConstraintIds.has(c.id) ? " (advisory: stale)" : " (advisory: low confidence)"
|
|
606
|
+
: "";
|
|
607
|
+
console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${fs.join(", ")}\n rationale: ${c.rationale || "—"}`);
|
|
577
608
|
}
|
|
578
609
|
}
|
|
579
610
|
if (near.size) {
|
|
580
|
-
console.log(`${direct.size ? "\n" : ""}Near ${near.size} invariant(s) via blast radius (a guarded dependency changed — review):\n`);
|
|
611
|
+
console.log(`${direct.size ? "\n" : ""}Near ${near.size} invariant(s) via blast radius (a guarded dependency changed — review; never blocks):\n`);
|
|
581
612
|
for (const { c, via } of near.values()) {
|
|
582
|
-
if (c.severity === "blocking")
|
|
583
|
-
blocking++;
|
|
584
613
|
console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id}\n ${via.slice(0, 4).join("\n ")}${via.length > 4 ? `\n …+${via.length - 4} more path(s)` : ""}`);
|
|
585
614
|
}
|
|
586
615
|
}
|
|
@@ -590,16 +619,19 @@ program
|
|
|
590
619
|
console.log(` ${h.blocking ? "⛔" : "⚠"} re-adds ${h.kind} \`${h.name}\` — ${h.decision} removed it${h.blocking ? " (blocking-linked)" : ""}\n “${h.title}”\n ${h.reason}`);
|
|
591
620
|
}
|
|
592
621
|
}
|
|
593
|
-
if (opts.strict && (
|
|
622
|
+
if (opts.strict && (strictBlockers || regBlocking)) {
|
|
594
623
|
const reasons = [
|
|
595
|
-
|
|
624
|
+
strictBlockers ? `${strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
|
|
596
625
|
regBlocking ? `${regBlocking} blocking-linked regression(s)` : "",
|
|
597
626
|
].filter(Boolean).join(" + ");
|
|
598
627
|
console.log(`\n✗ ${reasons} — review before committing.`);
|
|
599
628
|
process.exitCode = 1;
|
|
600
629
|
}
|
|
630
|
+
else if (opts.strict) {
|
|
631
|
+
console.log(`\nReview these — none are a direct, high-confidence, non-stale blocking invariant, so the commit is NOT blocked.`);
|
|
632
|
+
}
|
|
601
633
|
else {
|
|
602
|
-
console.log(`\nReview that these invariants still hold. (Advisory — run with --strict to fail on blocking.)`);
|
|
634
|
+
console.log(`\nReview that these invariants still hold. (Advisory — run with --strict to fail on direct, high-confidence, non-stale blocking invariants.)`);
|
|
603
635
|
}
|
|
604
636
|
store.close();
|
|
605
637
|
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The hardened gate for `hunch check --strict` (and the strict pre-commit hook):
|
|
2
|
+
* which invariants may actually FAIL a commit. Extracted + pure so the rule lives
|
|
3
|
+
* in one audited, unit-tested place (mirrors hookpolicy.ts).
|
|
4
|
+
*
|
|
5
|
+
* A commit is only ever blocked by a DIRECTLY-scoped, high-confidence, NON-STALE
|
|
6
|
+
* blocking invariant — never by a blast-radius ("near") guess, nor by a record the
|
|
7
|
+
* graph may have gone stale on, nor by a low-confidence auto-derived guess. Those
|
|
8
|
+
* weaker signals still print, as advisory. This makes strict mode safe to enable
|
|
9
|
+
* on a shared repo: a false positive downgrades to a warning instead of wrongly
|
|
10
|
+
* failing a teammate's commit. */
|
|
11
|
+
export const STRICT_MIN_CONFIDENCE = 0.8;
|
|
12
|
+
/** May this invariant FAIL a commit under --strict? Requires blocking severity,
|
|
13
|
+
* a fresh (non-stale) record, and either high provenance confidence or a
|
|
14
|
+
* human-confirmed source (a person vouched for it). Near/blast-radius hits never
|
|
15
|
+
* reach here — the caller passes only directly-scoped invariants. */
|
|
16
|
+
export function isStrictBlocker(c, stale) {
|
|
17
|
+
if (c.severity !== "blocking")
|
|
18
|
+
return false;
|
|
19
|
+
if (stale)
|
|
20
|
+
return false;
|
|
21
|
+
const confidence = c.provenance?.confidence ?? 0;
|
|
22
|
+
return confidence >= STRICT_MIN_CONFIDENCE || c.provenance?.source === "human_confirmed";
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=strictgate.js.map
|
|
@@ -52,7 +52,9 @@ const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
|
|
|
52
52
|
const PRE_END = "# <<< hunch pre-commit <<<";
|
|
53
53
|
/** Install a pre-commit constraint guard (DESIGN §4 enforcement). Advisory by
|
|
54
54
|
* default (prints invariants in scope, never blocks); pass strict to fail the
|
|
55
|
-
* commit
|
|
55
|
+
* commit — but even strict only fails on a DIRECT, high-confidence, non-stale
|
|
56
|
+
* blocking invariant (see strictgate.ts), so it's safe on a shared repo.
|
|
57
|
+
* Preserves any existing pre-commit hook. */
|
|
56
58
|
export function installPreCommitHook(root, invocation, strict = false) {
|
|
57
59
|
const dir = hooksDir(root);
|
|
58
60
|
const abs = dir.startsWith("/") ? dir : join(root, dir);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"license": "MIT",
|
|
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.",
|