@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 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 blocking invariant (direct or near)")
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 — blocks on blocking invariants, direct or near" : "advisory — flags invariants in scope or blast radius"})`);
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
- console.log(`Backfilling from ${commits.length} commit(s) since ${opts.since}…`);
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
- for (const sha of commits) {
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 if a blocking constraint is in scope (direct OR near)")
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
- let blocking = 0;
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
- if (c.severity === "blocking")
575
- blocking++;
576
- console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id} · in: ${fs.join(", ")}\n rationale: ${c.rationale || "—"}`);
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 && (blocking || regBlocking)) {
622
+ if (opts.strict && (strictBlockers || regBlocking)) {
594
623
  const reasons = [
595
- blocking ? `${blocking} blocking invariant(s) in scope` : "",
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 on a blocking invariant. Preserves any existing pre-commit hook. */
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.10.2",
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.",