@davesheffer/hunch 0.17.2 → 0.18.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/README.md +9 -1
- package/dist/cli/index.js +25 -6
- package/dist/integrations/hooks.js +5 -3
- package/dist/synthesis/provider.js +89 -0
- package/dist/synthesis/synthesize.js +9 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,6 +86,12 @@ afterward to pick up the `hunch_*` tools. Each teammate runs `hunch init` once;
|
|
|
86
86
|
> Synthesis is billed to **your coding-assistant subscription** (Claude/Codex/Cursor CLI),
|
|
87
87
|
> **never** a pay-per-token API key — and falls back to a deterministic heuristic if no CLI
|
|
88
88
|
> is present. Details: [Synthesis & billing](https://hunch-pi.vercel.app/docs#synthesis).
|
|
89
|
+
>
|
|
90
|
+
> **Deep Synthesis** (`backfill --deep` / `sync --deep`): if you're signed into more than one
|
|
91
|
+
> CLI, fan the commit out to *all* of them and reconcile the drafts into one — confidence is
|
|
92
|
+
> **agreement-weighted** (capped below the enforcement threshold, so it stays advisory).
|
|
93
|
+
> Subscription-only, never on the guard path, and it degrades to the single-provider path with
|
|
94
|
+
> one CLI.
|
|
89
95
|
> On Windows, prefer `hunch init` over a global `claude mcp add`; if tools don't appear,
|
|
90
96
|
> `hunch doctor` heals it ([why](https://hunch-pi.vercel.app/docs#windows)).
|
|
91
97
|
|
|
@@ -168,7 +174,9 @@ env var, no shell-profile edit** (and `HUNCH_PRIVATE_DIR` still overrides per-sh
|
|
|
168
174
|
default-off** (no config → fully inert), and **leak-safe by construction**: committed files and
|
|
169
175
|
the CI PR comment render *public-only*, so a private record can't reach a public surface. Record
|
|
170
176
|
sensitive items with `private: true` (`hunch_record_decision` / `hunch_record_correction`);
|
|
171
|
-
post-commit synthesis can route there too
|
|
177
|
+
post-commit synthesis can route there too, and `hunch private --auto-commit` (opt-in)
|
|
178
|
+
auto-commits + pushes each capture to the private repo — recursion-safe, staging only `.hunch/`.
|
|
179
|
+
→ [docs](https://hunch-pi.vercel.app/docs#private)
|
|
172
180
|
|
|
173
181
|
## Continuous learning (CI)
|
|
174
182
|
|
package/dist/cli/index.js
CHANGED
|
@@ -67,6 +67,7 @@ program
|
|
|
67
67
|
.option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
|
|
68
68
|
.option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
|
|
69
69
|
.option("--private-sync", "post-commit synthesis writes captured decisions into the private overlay (HUNCH_PRIVATE_DIR), never the public repo")
|
|
70
|
+
.option("--auto-commit", "opt-in: the post-commit hook also git add+commit+pushes the captured decision (the repo it landed in)")
|
|
70
71
|
.action((opts) => {
|
|
71
72
|
// Validate --firmness up front, before any side effects (indexing, git hooks,
|
|
72
73
|
// .mcp.json) or opening the store — a bad value must not leave a half-init.
|
|
@@ -95,8 +96,8 @@ program
|
|
|
95
96
|
console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
|
|
96
97
|
}
|
|
97
98
|
if (isGitRepo(root)) {
|
|
98
|
-
const h = installPostCommitHook(root, inv.shell, { private: opts.privateSync });
|
|
99
|
-
console.log(` ✓ post-commit hook ${h.action} (learning loop)${opts.privateSync ? " — syncs to the private overlay" : ""}`);
|
|
99
|
+
const h = installPostCommitHook(root, inv.shell, { private: opts.privateSync, commit: opts.autoCommit });
|
|
100
|
+
console.log(` ✓ post-commit hook ${h.action} (learning loop)${opts.privateSync ? " — syncs to the private overlay" : ""}${opts.autoCommit ? " — auto-commit+push on" : ""}`);
|
|
100
101
|
const m = installMergeDriver(root, inv.shell);
|
|
101
102
|
console.log(` ✓ team merge driver ${m.action}`);
|
|
102
103
|
// Auto-install the pre-commit guard by default (advisory: flags invariants
|
|
@@ -185,6 +186,7 @@ program
|
|
|
185
186
|
.option("--since <spec>", "how far back, e.g. 90d", "90d")
|
|
186
187
|
.option("--max <n>", "max commits to process", "40")
|
|
187
188
|
.option("--concurrency <n>", "commits to synthesize in parallel (the LLM call is the bottleneck)", "4")
|
|
189
|
+
.option("--deep", "Deep Synthesis: ensemble every available subscription CLI per commit and reconcile their drafts (slower, higher-quality; advisory)")
|
|
188
190
|
.action(async (opts) => {
|
|
189
191
|
const { store, root } = storeFor();
|
|
190
192
|
if (!isGitRepo(root))
|
|
@@ -199,7 +201,7 @@ program
|
|
|
199
201
|
// and the store's JS-side reads/writes run synchronously between awaits (single
|
|
200
202
|
// thread) — only the LLM spawns overlap. reindex() runs once, after the pool.
|
|
201
203
|
await mapPool(commits, conc, async (sha) => {
|
|
202
|
-
const r = await syncCommit(store, root, sha);
|
|
204
|
+
const r = await syncCommit(store, root, sha, { deep: opts.deep });
|
|
203
205
|
if (r.status === "written") {
|
|
204
206
|
written++;
|
|
205
207
|
if (r.provider === "claude-cli")
|
|
@@ -227,6 +229,8 @@ program
|
|
|
227
229
|
.option("--quiet", "minimal output")
|
|
228
230
|
.option("--force", "re-synthesize even if a decision already exists for the commit")
|
|
229
231
|
.option("--private", "write the synthesized decision into the private overlay (HUNCH_PRIVATE_DIR), not the public repo — for a repo whose memory is kept private")
|
|
232
|
+
.option("--commit", "after a capture, also git add+commit+push the repo the decision landed in (opt-in; best-effort) — the private store under --private, else this repo")
|
|
233
|
+
.option("--deep", "Deep Synthesis: ensemble every available subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
|
|
230
234
|
.action(async (sha, opts) => {
|
|
231
235
|
const { store, root } = storeFor();
|
|
232
236
|
if (!isGitRepo(root))
|
|
@@ -236,13 +240,27 @@ program
|
|
|
236
240
|
return opts.quiet ? undefined : fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
237
241
|
}
|
|
238
242
|
store.json.ensureDirs();
|
|
239
|
-
const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: opts.private });
|
|
243
|
+
const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: opts.private, deep: opts.deep });
|
|
240
244
|
if (r.status === "written") {
|
|
241
245
|
store.reindex();
|
|
242
246
|
// Don't rewrite CLAUDE.md from the hook — it would dirty the working tree
|
|
243
247
|
// on every commit. `hunch index`/`init` refresh it intentionally instead.
|
|
244
248
|
if (!opts.fromHook)
|
|
245
249
|
updateClaudeMd(root, store);
|
|
250
|
+
// Opt-in: persist the captured decision in the repo it landed in (private store
|
|
251
|
+
// under --private, else this repo). Best-effort — a non-repo dir / offline push
|
|
252
|
+
// just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
|
|
253
|
+
// changes), and set HUNCH_SYNC=1 so the commit we create can't re-trigger this
|
|
254
|
+
// hook (no recursion, including on a manual `hunch sync --commit`).
|
|
255
|
+
const commitTarget = opts.commit ? (opts.private ? store.privateDir : hunchPaths(root).hunch) : undefined;
|
|
256
|
+
if (commitTarget) {
|
|
257
|
+
const g = (args) => { spawnSync("git", ["-C", commitTarget, ...args], { stdio: "ignore", env: { ...process.env, HUNCH_SYNC: "1" } }); };
|
|
258
|
+
g(["add", "--", "."]);
|
|
259
|
+
g(["commit", "-m", `hunch: capture ${r.decision?.id ?? "decision"}`]);
|
|
260
|
+
g(["push"]);
|
|
261
|
+
if (!opts.quiet)
|
|
262
|
+
console.log(` ↳ committed + pushed ${r.decision?.id} (${commitTarget})`);
|
|
263
|
+
}
|
|
246
264
|
if (!opts.quiet)
|
|
247
265
|
console.log(`✓ captured decision ${r.decision?.id} via ${r.provider}: "${r.decision?.title}"`);
|
|
248
266
|
}
|
|
@@ -257,6 +275,7 @@ program
|
|
|
257
275
|
.description("Enable a PRIVATE memory overlay — sensitive decisions/bugs/constraints kept in a separate location, unioned into local queries, never committed here. Writes a gitignored .hunch/local.json so it's auto-detected (no env var needed).")
|
|
258
276
|
.option("--repo <url>", "clone a private git repo to use as the store (into ./.hunch-private)")
|
|
259
277
|
.option("--no-hook", "don't switch the post-commit hook to private sync")
|
|
278
|
+
.option("--auto-commit", "opt-in: the post-commit hook also git add+commit+pushes the private repo after each capture")
|
|
260
279
|
.action((dir, opts) => {
|
|
261
280
|
const root = findRoot();
|
|
262
281
|
const paths = hunchPaths(root);
|
|
@@ -290,8 +309,8 @@ program
|
|
|
290
309
|
let hookNote = "";
|
|
291
310
|
if (opts.hook && isGitRepo(root)) {
|
|
292
311
|
const inv = resolveInvocation();
|
|
293
|
-
const h = installPostCommitHook(root, inv.shell, { private: true });
|
|
294
|
-
hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here\n`;
|
|
312
|
+
const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit });
|
|
313
|
+
hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
|
|
295
314
|
}
|
|
296
315
|
console.log(`✓ private overlay enabled → ${hunchDir}\n` +
|
|
297
316
|
` ✓ recorded in .hunch/local.json (gitignored) — auto-detected, no env var or shell-profile edit\n` +
|
|
@@ -11,14 +11,16 @@ const MARK = "# >>> hunch post-commit >>>";
|
|
|
11
11
|
const ENDMARK = "# <<< hunch post-commit <<<";
|
|
12
12
|
function block(invocation, opts = {}) {
|
|
13
13
|
// --private routes the auto-synthesized decision into the HUNCH_PRIVATE_DIR overlay
|
|
14
|
-
// instead of the public repo.
|
|
15
|
-
//
|
|
14
|
+
// instead of the public repo. --commit (opt-in) also commits & pushes the repo the
|
|
15
|
+
// decision landed in (the private store under --private, else this repo). The hook
|
|
16
|
+
// script is local (.git/hooks/), never committed.
|
|
16
17
|
const priv = opts.private ? " --private" : "";
|
|
18
|
+
const commit = opts.commit ? " --commit" : "";
|
|
17
19
|
return [
|
|
18
20
|
MARK,
|
|
19
21
|
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
20
22
|
" export HUNCH_SYNC=1",
|
|
21
|
-
` ( ${invocation} sync --from-hook --quiet${priv} >/dev/null 2>&1 || true ) &`,
|
|
23
|
+
` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
|
|
22
24
|
"fi",
|
|
23
25
|
ENDMARK,
|
|
24
26
|
].join("\n");
|
|
@@ -418,6 +418,95 @@ export async function selectProvider() {
|
|
|
418
418
|
}
|
|
419
419
|
return new DeterministicProvider();
|
|
420
420
|
}
|
|
421
|
+
// ---- Deep Synthesis: ensemble of subscription CLIs ------------------------
|
|
422
|
+
// Opt-in (backfill/sync --deep): fan a commit out to EVERY available subscription
|
|
423
|
+
// CLI, drop failures, and reconcile the drafts. Subscription-only (the workers are
|
|
424
|
+
// the same CLI providers, so ANTHROPIC_API_KEY stripping is inherited). NEVER used on
|
|
425
|
+
// the guard path; confidence is capped below the strict gate so output stays advisory.
|
|
426
|
+
/** All available subscription-CLI workers (claude/codex/cursor), excluding the
|
|
427
|
+
* deterministic fallback — the pool Deep Synthesis fans a commit out to. */
|
|
428
|
+
export async function selectWorkers() {
|
|
429
|
+
const out = [];
|
|
430
|
+
for (const p of PROVIDERS) {
|
|
431
|
+
if (p.name === "deterministic")
|
|
432
|
+
continue; // workers are real subscription CLIs only
|
|
433
|
+
if (await isAvailable(p))
|
|
434
|
+
out.push(p);
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
const tokens = (d) => new Set(`${d.title} ${d.decision}`.toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []);
|
|
439
|
+
/** Mean pairwise Jaccard overlap of the drafts' identifying text (0..1) — how much the
|
|
440
|
+
* independent workers AGREE. Drives the merged confidence. */
|
|
441
|
+
function meanAgreement(drafts) {
|
|
442
|
+
if (drafts.length < 2)
|
|
443
|
+
return 1;
|
|
444
|
+
const sets = drafts.map(tokens);
|
|
445
|
+
let sum = 0, pairs = 0;
|
|
446
|
+
for (let i = 0; i < sets.length; i++)
|
|
447
|
+
for (let j = i + 1; j < sets.length; j++) {
|
|
448
|
+
const a = sets[i], b = sets[j];
|
|
449
|
+
let inter = 0;
|
|
450
|
+
for (const t of a)
|
|
451
|
+
if (b.has(t))
|
|
452
|
+
inter++;
|
|
453
|
+
const union = a.size + b.size - inter;
|
|
454
|
+
sum += union ? inter / union : 0;
|
|
455
|
+
pairs++; // two empty drafts don't meaningfully "agree"
|
|
456
|
+
}
|
|
457
|
+
return pairs ? sum / pairs : 1;
|
|
458
|
+
}
|
|
459
|
+
const dedupLines = (xs) => [...new Set(xs.map((s) => s.trim()).filter(Boolean))];
|
|
460
|
+
/** Reconcile N worker drafts into one. DETERMINISTIC (no second LLM call): the richest
|
|
461
|
+
* draft is the spine; alternatives/consequences are unioned; confidence is AGREEMENT-
|
|
462
|
+
* WEIGHTED and CAPPED at 0.78 — below STRICT_MIN_CONFIDENCE (0.8) — so an ensemble
|
|
463
|
+
* auto-draft can never arm enforcement. */
|
|
464
|
+
export function mergeDecisionDrafts(drafts) {
|
|
465
|
+
const primary = [...drafts].sort((a, b) => b.confidence - a.confidence || b.decision.length - a.decision.length)[0];
|
|
466
|
+
const agreement = meanAgreement(drafts);
|
|
467
|
+
return {
|
|
468
|
+
title: primary.title,
|
|
469
|
+
context: primary.context,
|
|
470
|
+
decision: primary.decision,
|
|
471
|
+
consequences: dedupLines(drafts.flatMap((d) => d.consequences)),
|
|
472
|
+
alternatives_rejected: dedupLines(drafts.flatMap((d) => d.alternatives_rejected)),
|
|
473
|
+
confidence: Math.min(0.78, 0.55 + 0.23 * agreement),
|
|
474
|
+
source: "llm_draft+ensemble",
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
export class EnsembleProvider {
|
|
478
|
+
workers;
|
|
479
|
+
name = "ensemble";
|
|
480
|
+
constructor(workers) {
|
|
481
|
+
this.workers = workers;
|
|
482
|
+
}
|
|
483
|
+
async available() { return this.workers.length > 0; }
|
|
484
|
+
async draftDecision(input) {
|
|
485
|
+
if (!this.workers.length)
|
|
486
|
+
throw new Error("ensemble: no subscription CLI workers available");
|
|
487
|
+
const settled = await Promise.allSettled(this.workers.map((w) => w.draftDecision(input)));
|
|
488
|
+
const drafts = settled.flatMap((s) => (s.status === "fulfilled" ? [s.value] : []));
|
|
489
|
+
if (!drafts.length)
|
|
490
|
+
throw new Error("ensemble: all workers failed");
|
|
491
|
+
return drafts.length === 1 ? drafts[0] : mergeDecisionDrafts(drafts);
|
|
492
|
+
}
|
|
493
|
+
async draftBug(input) {
|
|
494
|
+
// Bug ensembling is deferred — use the first worker that succeeds.
|
|
495
|
+
for (const w of this.workers) {
|
|
496
|
+
try {
|
|
497
|
+
return await w.draftBug(input);
|
|
498
|
+
}
|
|
499
|
+
catch { /* try next */ }
|
|
500
|
+
}
|
|
501
|
+
throw new Error("ensemble: all workers failed for bug");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Build the Deep-Synthesis provider, or null if no subscription CLI is available
|
|
505
|
+
* (the caller then falls back to the normal single-provider path). */
|
|
506
|
+
export async function selectEnsemble() {
|
|
507
|
+
const workers = await selectWorkers();
|
|
508
|
+
return workers.length ? new EnsembleProvider(workers) : null;
|
|
509
|
+
}
|
|
421
510
|
// ---- prompt + parsing helpers --------------------------------------------
|
|
422
511
|
// Above this size we stop shipping the raw patch and lean on the deterministic
|
|
423
512
|
// STRUCTURED CHANGES summary + a small sample. A truncated head-slice of a giant
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { commitMeta, commitDiff, headSha } from "../extractors/git.js";
|
|
2
2
|
import { analyzeDiff } from "../extractors/diff.js";
|
|
3
|
-
import { selectProvider, DeterministicProvider } from "./provider.js";
|
|
3
|
+
import { selectProvider, selectEnsemble, DeterministicProvider } from "./provider.js";
|
|
4
4
|
import { decisionId, bugId, constraintId } from "../core/ids.js";
|
|
5
5
|
import { pathMatchesGlob } from "../core/glob.js";
|
|
6
6
|
import { draftTripwires, knownRepoDeps } from "./tripwires.js";
|
|
@@ -66,9 +66,14 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
66
66
|
// Significance gate: reserve the paid LLM for substantive commits; trivial ones
|
|
67
67
|
// get the FREE deterministic draft (honestly labeled "inferred"/low-confidence,
|
|
68
68
|
// so the Hunch stays accurate-by-provenance). --force always uses the provider.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
// Deep Synthesis (--deep): ensemble every available subscription CLI and reconcile
|
|
70
|
+
// their drafts (agreement-weighted, confidence capped below the strict gate). Falls
|
|
71
|
+
// back to the normal single-provider path when no CLI is available. Opt-in only.
|
|
72
|
+
const provider = opts.deep
|
|
73
|
+
? (await selectEnsemble()) ?? await selectProvider()
|
|
74
|
+
: opts.force || isSignificant(meta, analysis, codeFiles)
|
|
75
|
+
? await selectProvider()
|
|
76
|
+
: new DeterministicProvider();
|
|
72
77
|
const input = { subject: meta.subject, body: meta.body, files: codeFiles, diff, analysis };
|
|
73
78
|
const draft = await draftDecisionSafe(provider, input);
|
|
74
79
|
const components = store.json.loadAll("components");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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.",
|