@davesheffer/hunch 0.17.3 → 0.18.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 +11 -3
- package/dist/cli/index.js +19 -9
- package/dist/extractors/git.js +17 -0
- package/dist/mcp/server.js +15 -3
- package/dist/store/hunchStore.js +13 -7
- 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
|
|
|
@@ -213,7 +221,7 @@ src/
|
|
|
213
221
|
|
|
214
222
|
Everything lives under `.hunch/` as git-tracked JSON (the source of truth); SQLite is a
|
|
215
223
|
throwaway derived index. → [storage layout](https://hunch-pi.vercel.app/docs#storage) ·
|
|
216
|
-
[
|
|
224
|
+
[the docs](https://hunch-pi.vercel.app/docs) for the full conceptual model.
|
|
217
225
|
|
|
218
226
|
## Notable engineering decisions
|
|
219
227
|
|
|
@@ -239,5 +247,5 @@ npm run build # compile to dist/ (the published artifact)
|
|
|
239
247
|
```
|
|
240
248
|
|
|
241
249
|
Hunch is pure TypeScript ESM, Node ≥ 20, licensed **Apache-2.0**. See
|
|
242
|
-
[CONTRIBUTING.md](CONTRIBUTING.md)
|
|
250
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) and the full
|
|
243
251
|
[developer docs](https://hunch-pi.vercel.app/docs#develop).
|
package/dist/cli/index.js
CHANGED
|
@@ -28,7 +28,7 @@ import { indexRepo } from "../extractors/indexer.js";
|
|
|
28
28
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
29
29
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
30
30
|
import { selectProvider } from "../synthesis/provider.js";
|
|
31
|
-
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists } from "../extractors/git.js";
|
|
31
|
+
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists, commitAndPushHunch } from "../extractors/git.js";
|
|
32
32
|
import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
|
|
33
33
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
34
34
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
@@ -186,6 +186,7 @@ program
|
|
|
186
186
|
.option("--since <spec>", "how far back, e.g. 90d", "90d")
|
|
187
187
|
.option("--max <n>", "max commits to process", "40")
|
|
188
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)")
|
|
189
190
|
.action(async (opts) => {
|
|
190
191
|
const { store, root } = storeFor();
|
|
191
192
|
if (!isGitRepo(root))
|
|
@@ -200,7 +201,7 @@ program
|
|
|
200
201
|
// and the store's JS-side reads/writes run synchronously between awaits (single
|
|
201
202
|
// thread) — only the LLM spawns overlap. reindex() runs once, after the pool.
|
|
202
203
|
await mapPool(commits, conc, async (sha) => {
|
|
203
|
-
const r = await syncCommit(store, root, sha);
|
|
204
|
+
const r = await syncCommit(store, root, sha, { deep: opts.deep });
|
|
204
205
|
if (r.status === "written") {
|
|
205
206
|
written++;
|
|
206
207
|
if (r.provider === "claude-cli")
|
|
@@ -229,6 +230,7 @@ program
|
|
|
229
230
|
.option("--force", "re-synthesize even if a decision already exists for the commit")
|
|
230
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")
|
|
231
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")
|
|
232
234
|
.action(async (sha, opts) => {
|
|
233
235
|
const { store, root } = storeFor();
|
|
234
236
|
if (!isGitRepo(root))
|
|
@@ -238,7 +240,7 @@ program
|
|
|
238
240
|
return opts.quiet ? undefined : fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
239
241
|
}
|
|
240
242
|
store.json.ensureDirs();
|
|
241
|
-
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 });
|
|
242
244
|
if (r.status === "written") {
|
|
243
245
|
store.reindex();
|
|
244
246
|
// Don't rewrite CLAUDE.md from the hook — it would dirty the working tree
|
|
@@ -252,10 +254,7 @@ program
|
|
|
252
254
|
// hook (no recursion, including on a manual `hunch sync --commit`).
|
|
253
255
|
const commitTarget = opts.commit ? (opts.private ? store.privateDir : hunchPaths(root).hunch) : undefined;
|
|
254
256
|
if (commitTarget) {
|
|
255
|
-
|
|
256
|
-
g(["add", "--", "."]);
|
|
257
|
-
g(["commit", "-m", `hunch: capture ${r.decision?.id ?? "decision"}`]);
|
|
258
|
-
g(["push"]);
|
|
257
|
+
commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`);
|
|
259
258
|
if (!opts.quiet)
|
|
260
259
|
console.log(` ↳ committed + pushed ${r.decision?.id} (${commitTarget})`);
|
|
261
260
|
}
|
|
@@ -273,9 +272,20 @@ program
|
|
|
273
272
|
.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).")
|
|
274
273
|
.option("--repo <url>", "clone a private git repo to use as the store (into ./.hunch-private)")
|
|
275
274
|
.option("--no-hook", "don't switch the post-commit hook to private sync")
|
|
276
|
-
.option("--auto-commit", "opt-in:
|
|
275
|
+
.option("--auto-commit", "opt-in: also git add+commit+push the private repo after each capture (post-commit hook AND MCP private writes)")
|
|
276
|
+
.option("--sync", "flush the configured private store now (git add+commit+push) — catches records made via MCP between commits")
|
|
277
277
|
.action((dir, opts) => {
|
|
278
278
|
const root = findRoot();
|
|
279
|
+
if (opts.sync) {
|
|
280
|
+
const s = new HunchStore(hunchPaths(root));
|
|
281
|
+
const target = s.privateDir;
|
|
282
|
+
s.close();
|
|
283
|
+
if (!target)
|
|
284
|
+
return fail("no private overlay configured — run `hunch private` first");
|
|
285
|
+
commitAndPushHunch(target, "hunch: sync private memory");
|
|
286
|
+
console.log(`✓ flushed private store → ${target}`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
279
289
|
const paths = hunchPaths(root);
|
|
280
290
|
// 1) resolve the private store's hunch dir (holds decisions/, bugs/, …)
|
|
281
291
|
let hunchDir;
|
|
@@ -301,7 +311,7 @@ program
|
|
|
301
311
|
// a store elsewhere on disk. Resolution (env || local.json) re-resolves against root.
|
|
302
312
|
const rel = relative(root, hunchDir);
|
|
303
313
|
const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
|
|
304
|
-
writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored }, null, 2) + "\n");
|
|
314
|
+
writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored, autoCommit: !!opts.autoCommit }, null, 2) + "\n");
|
|
305
315
|
ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
|
|
306
316
|
// 4) route post-commit synthesis to the overlay (local hook, never committed)
|
|
307
317
|
let hookNote = "";
|
package/dist/extractors/git.js
CHANGED
|
@@ -19,6 +19,23 @@ function gitSafe(args, cwd, maxBuffer) {
|
|
|
19
19
|
export function isGitRepo(cwd) {
|
|
20
20
|
return gitSafe(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
|
|
21
21
|
}
|
|
22
|
+
/** Best-effort: stage ONLY the hunch dir, commit, and push the repo it lives in. Shared by
|
|
23
|
+
* the post-commit auto-commit (CLI sync --commit), MCP private writes, and `hunch private
|
|
24
|
+
* --sync`. HUNCH_SYNC=1 stops the created commit from re-triggering the post-commit hook
|
|
25
|
+
* (no recursion). Stages with a pathspec scoped to `hunchDir`, so it never sweeps unrelated
|
|
26
|
+
* working-tree changes. Never throws — a non-repo dir / offline push just no-ops. */
|
|
27
|
+
export function commitAndPushHunch(hunchDir, message) {
|
|
28
|
+
const env = { ...process.env, HUNCH_SYNC: "1" };
|
|
29
|
+
const run = (args) => {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
|
|
32
|
+
}
|
|
33
|
+
catch { /* best-effort: nothing staged / not a repo / offline */ }
|
|
34
|
+
};
|
|
35
|
+
run(["add", "--", "."]);
|
|
36
|
+
run(["commit", "-m", message]);
|
|
37
|
+
run(["push"]);
|
|
38
|
+
}
|
|
22
39
|
export function headSha(cwd) {
|
|
23
40
|
return gitSafe(["rev-parse", "HEAD"], cwd);
|
|
24
41
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -14,7 +14,7 @@ import { HunchStore } from "../store/hunchStore.js";
|
|
|
14
14
|
import { selectEmbedder } from "../store/embedder.js";
|
|
15
15
|
import { decisionId } from "../core/ids.js";
|
|
16
16
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
17
|
-
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff } from "../extractors/git.js";
|
|
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
19
|
import { renderMarkdown, verdict } from "../core/checkreport.js";
|
|
20
20
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
@@ -292,9 +292,16 @@ export function buildServer(root) {
|
|
|
292
292
|
// the public store, so skip it for a private record (a v1 limitation, not a leak).
|
|
293
293
|
const superseded = decision.supersedes && !decision.private ? store.supersede(decision.supersedes, rec) : null;
|
|
294
294
|
store.reindex();
|
|
295
|
+
// Auto-flush the private repo when configured (hunch private --auto-commit), so a
|
|
296
|
+
// record made via MCP between public commits is committed+pushed immediately.
|
|
297
|
+
let flushed = "";
|
|
298
|
+
if (decision.private && store.privateAutoCommit && store.privateDir) {
|
|
299
|
+
commitAndPushHunch(store.privateDir, `hunch: capture ${id}`);
|
|
300
|
+
flushed = " (committed + pushed to the private repo)";
|
|
301
|
+
}
|
|
295
302
|
const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
|
|
296
303
|
const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
|
|
297
|
-
const where = decision.private ?
|
|
304
|
+
const where = decision.private ? ` [PRIVATE overlay — not committed to this repo]${flushed}` : "";
|
|
298
305
|
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}`);
|
|
299
306
|
}
|
|
300
307
|
catch (e) {
|
|
@@ -328,10 +335,15 @@ export function buildServer(root) {
|
|
|
328
335
|
else
|
|
329
336
|
store.json.put("constraints", rec);
|
|
330
337
|
store.reindex();
|
|
338
|
+
let flushed = "";
|
|
339
|
+
if (input.private && store.privateAutoCommit && store.privateDir) {
|
|
340
|
+
commitAndPushHunch(store.privateDir, `hunch: capture ${rec.id}`);
|
|
341
|
+
flushed = " (committed + pushed to the private repo)";
|
|
342
|
+
}
|
|
331
343
|
const enforce = rec.severity === "blocking"
|
|
332
344
|
? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
|
|
333
345
|
: "flags violating edits and PRs (advisory)";
|
|
334
|
-
const where = input.private ?
|
|
346
|
+
const where = input.private ? ` [PRIVATE overlay — not committed to this repo]${flushed}` : "";
|
|
335
347
|
return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.`);
|
|
336
348
|
}
|
|
337
349
|
catch (e) {
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -31,6 +31,9 @@ export class HunchStore {
|
|
|
31
31
|
/** The resolved private-overlay hunch dir (from env or .hunch/local.json), or undefined
|
|
32
32
|
* when no overlay is configured. Surfaced so `hunch doctor` reflects the true state. */
|
|
33
33
|
privateDir;
|
|
34
|
+
/** Whether private writes should auto commit+push the private repo (from local.json
|
|
35
|
+
* `autoCommit`, set by `hunch private --auto-commit`). Read by the MCP write tools. */
|
|
36
|
+
privateAutoCommit;
|
|
34
37
|
/** When true, recs() ignores the private overlay (public-only). Set transiently by
|
|
35
38
|
* buildCheckReport({publicOnly}) so any PUBLICLY-POSTED report (the CI PR comment)
|
|
36
39
|
* can never render a private record — a publicly-posted output is a leak surface
|
|
@@ -43,24 +46,27 @@ export class HunchStore {
|
|
|
43
46
|
// Private overlay location: env (override, for CI / portability) → else a gitignored
|
|
44
47
|
// local config (.hunch/local.json) so `hunch private` enables it with NO env var, and
|
|
45
48
|
// the MCP server / hook pick it up automatically. Relative paths resolve from root.
|
|
46
|
-
const
|
|
49
|
+
const local = this.localConfig();
|
|
50
|
+
const priv = process.env.HUNCH_PRIVATE_DIR?.trim() || local.privateDir;
|
|
47
51
|
if (priv) {
|
|
48
52
|
this.privateDir = resolve(this.paths.root, priv);
|
|
49
53
|
this.privateJson = new JsonStore(hunchPathsForDir(this.privateDir));
|
|
50
54
|
}
|
|
55
|
+
this.privateAutoCommit = !!(priv && local.autoCommit);
|
|
51
56
|
}
|
|
52
|
-
/** The private-overlay
|
|
53
|
-
* never committed). Tolerant:
|
|
54
|
-
|
|
57
|
+
/** The private-overlay config from the gitignored `.hunch/local.json` (per-machine,
|
|
58
|
+
* never committed). Tolerant: returns {} on missing/invalid so reads never crash. */
|
|
59
|
+
localConfig() {
|
|
55
60
|
try {
|
|
56
61
|
const f = join(this.paths.hunch, "local.json");
|
|
57
62
|
if (!existsSync(f))
|
|
58
|
-
return
|
|
63
|
+
return {};
|
|
59
64
|
const v = JSON.parse(readFileSync(f, "utf8"));
|
|
60
|
-
|
|
65
|
+
const privateDir = typeof v.privateDir === "string" && v.privateDir.trim() ? v.privateDir.trim() : undefined;
|
|
66
|
+
return { privateDir, autoCommit: v.autoCommit === true };
|
|
61
67
|
}
|
|
62
68
|
catch {
|
|
63
|
-
return
|
|
69
|
+
return {};
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
/** Merged read: public ∪ private overlay (private wins on id collision). Every
|
|
@@ -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.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.",
|