@davesheffer/hunch 1.6.0 → 1.7.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 +6 -0
- package/dist/cli/index.js +75 -20
- package/dist/synthesis/provider.js +145 -37
- package/dist/synthesis/synthesize.js +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,6 +62,12 @@ it adds a native lifecycle adapter too.
|
|
|
62
62
|
Your memory is plain JSON that you own. Hunch adds a SQLite index only as a rebuildable derived
|
|
63
63
|
layer—your decisions never disappear into a proprietary hosted memory system.
|
|
64
64
|
|
|
65
|
+
Synthesis is just as portable: Hunch can use Claude Code, Codex, or Cursor through the subscription
|
|
66
|
+
CLI you choose. It never guesses which of several installed subscriptions to bill—set your local,
|
|
67
|
+
gitignored preference with `hunch provider codex-cli` (or `claude-cli` / `cursor-agent`); otherwise
|
|
68
|
+
Hunch uses a subscription only when exactly one is available, and falls back to deterministic local
|
|
69
|
+
drafting when the choice is ambiguous.
|
|
70
|
+
|
|
65
71
|
```text
|
|
66
72
|
Claude Code ─┐
|
|
67
73
|
Cursor ├── MCP ──> .hunch/ reasoning graph ──> deterministic checks
|
package/dist/cli/index.js
CHANGED
|
@@ -28,7 +28,7 @@ import { selectEmbedder } from "../store/embedder.js";
|
|
|
28
28
|
import { indexRepo } from "../extractors/indexer.js";
|
|
29
29
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
30
30
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
31
|
-
import { selectProvider } from "../synthesis/provider.js";
|
|
31
|
+
import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, } from "../synthesis/provider.js";
|
|
32
32
|
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
|
|
33
33
|
import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
|
|
34
34
|
import { runbookId, decisionId } from "../core/ids.js";
|
|
@@ -445,7 +445,20 @@ function configureOverlay(dir, opts, mode) {
|
|
|
445
445
|
// a store elsewhere on disk. Resolution (env || local.json) re-resolves against root.
|
|
446
446
|
const rel = relative(root, hunchDir);
|
|
447
447
|
const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
|
|
448
|
-
|
|
448
|
+
const localFile = join(paths.hunch, "local.json");
|
|
449
|
+
let existingLocal = {};
|
|
450
|
+
if (existsSync(localFile)) {
|
|
451
|
+
try {
|
|
452
|
+
const parsed = JSON.parse(readFileSync(localFile, "utf8"));
|
|
453
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object")
|
|
454
|
+
throw new Error("not an object");
|
|
455
|
+
existingLocal = parsed;
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return fail(`refusing to overwrite malformed local configuration: ${localFile}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
writeFileAtomic(localFile, JSON.stringify({ ...existingLocal, privateDir: stored, autoCommit: !!opts.autoCommit, mode }, null, 2) + "\n");
|
|
449
462
|
ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
|
|
450
463
|
// SHARED mode with a remote: publish the store's URL in a COMMITTED team.json, so a
|
|
451
464
|
// fresh clone / new teammate / headless agent auto-connects on `hunch init` (or MCP
|
|
@@ -1543,6 +1556,48 @@ program
|
|
|
1543
1556
|
const next = writeConfig(paths, { firmness: level }).firmness;
|
|
1544
1557
|
console.log(`✓ firmness set to ${next} (takes effect on the next agent edit — no restart needed).`);
|
|
1545
1558
|
});
|
|
1559
|
+
// ---- provider (per-user synthesis subscription choice) -------------------
|
|
1560
|
+
program
|
|
1561
|
+
.command("provider")
|
|
1562
|
+
.description("Show or set the local coding-assistant subscription Hunch may use for synthesis. Never changes team config.")
|
|
1563
|
+
.argument("[name]", `auto | ${SYNTH_PREFERENCES.filter((p) => p !== "auto").join(" | ")} (omit to inspect)`)
|
|
1564
|
+
.action(async (value) => {
|
|
1565
|
+
const root = findRoot();
|
|
1566
|
+
if (value != null) {
|
|
1567
|
+
const preference = value.trim();
|
|
1568
|
+
if (!SYNTH_PREFERENCES.includes(preference)) {
|
|
1569
|
+
return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
|
|
1570
|
+
}
|
|
1571
|
+
try {
|
|
1572
|
+
writeSynthesisPreference(root, preference);
|
|
1573
|
+
}
|
|
1574
|
+
catch (error) {
|
|
1575
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
1576
|
+
}
|
|
1577
|
+
console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
|
|
1578
|
+
}
|
|
1579
|
+
const resolution = await resolveSynthesisProvider({ root });
|
|
1580
|
+
const envValue = process.env.HUNCH_SYNTH_PROVIDER?.trim();
|
|
1581
|
+
const local = readSynthesisPreference(root);
|
|
1582
|
+
const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
|
|
1583
|
+
console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
|
|
1584
|
+
console.log(`preference: ${hasValidEnv ? `environment: ${envValue}` : `local: ${local}`}`);
|
|
1585
|
+
if (envValue && !hasValidEnv)
|
|
1586
|
+
console.log(dim(`HUNCH_SYNTH_PROVIDER=${envValue} is unknown and is being ignored.`));
|
|
1587
|
+
console.log("available:");
|
|
1588
|
+
for (const status of resolution.statuses) {
|
|
1589
|
+
const billing = status.subscription ? ` — ${status.subscription}` : "";
|
|
1590
|
+
console.log(` ${status.available ? "✓" : "·"} ${status.name}: ${status.label}${billing}`);
|
|
1591
|
+
}
|
|
1592
|
+
if (resolution.source === "ambiguous") {
|
|
1593
|
+
const choices = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => `hunch provider ${s.name}`);
|
|
1594
|
+
console.log(dim("Multiple subscription CLIs are available, so Hunch uses the free deterministic fallback rather than guessing which plan to spend."));
|
|
1595
|
+
console.log(`choose one: ${choices.join(" or ")}`);
|
|
1596
|
+
}
|
|
1597
|
+
else if (resolution.source === "unavailable-preference") {
|
|
1598
|
+
console.log(dim(`Your ${resolution.preference} preference is not available; Hunch is using the local deterministic fallback.`));
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1546
1601
|
// ---- status (enforcement readiness at a glance) ---------------------------
|
|
1547
1602
|
program
|
|
1548
1603
|
.command("status")
|
|
@@ -1965,7 +2020,7 @@ program
|
|
|
1965
2020
|
// and any per-draft failure degrades to "not judged" (kept for a human).
|
|
1966
2021
|
const verdicts = new Map();
|
|
1967
2022
|
if (opts.llm !== false && !opts.private) {
|
|
1968
|
-
const provider = await selectProvider();
|
|
2023
|
+
const provider = await selectProvider({ root });
|
|
1969
2024
|
if (provider.judgeDraft) {
|
|
1970
2025
|
// The candidate pool for duplicate_of / restatement: the LIVE, vouched records.
|
|
1971
2026
|
const existing = all
|
|
@@ -2284,7 +2339,7 @@ program
|
|
|
2284
2339
|
let prose;
|
|
2285
2340
|
let adoptionProse;
|
|
2286
2341
|
if (opts.llm !== false) {
|
|
2287
|
-
const provider = await selectProvider();
|
|
2342
|
+
const provider = await selectProvider({ root });
|
|
2288
2343
|
if (provider.draftProse) {
|
|
2289
2344
|
console.log(`Prose via ${provider.name} (subscription); the drift-bearing skeleton stays deterministic.`);
|
|
2290
2345
|
prose = (pack, excerpts) => provider.draftProse(wikiPrompt(pack, excerpts));
|
|
@@ -2516,24 +2571,24 @@ program
|
|
|
2516
2571
|
const onDisk = readManifest(hunchPaths(root)).schema_version;
|
|
2517
2572
|
const schemaNote = onDisk === SCHEMA_VERSION ? "" : onDisk > SCHEMA_VERSION ? ` ⚠ newer than this Hunch (v${SCHEMA_VERSION}) — upgrade hunch` : ` ⚠ run \`hunch migrate\``;
|
|
2518
2573
|
console.log(`schema: v${onDisk} (hunch v${SCHEMA_VERSION})${schemaNote}`);
|
|
2519
|
-
const
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2574
|
+
const resolution = await resolveSynthesisProvider({ root });
|
|
2575
|
+
const provider = resolution.provider;
|
|
2576
|
+
console.log(`synthesis: ${provider.name} (${resolution.source})`);
|
|
2577
|
+
const selected = resolution.statuses.find((s) => s.name === provider.name);
|
|
2578
|
+
if (selected?.subscription) {
|
|
2579
|
+
console.log(` ↳ LLM synthesis uses your ${selected.subscription}; provider API credentials are not used.`);
|
|
2580
|
+
}
|
|
2581
|
+
else if (resolution.source === "ambiguous") {
|
|
2582
|
+
const names = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => s.name);
|
|
2583
|
+
console.log(dim(` ↳ ${names.join(", ")} are available; Hunch will not guess which subscription to spend.`));
|
|
2584
|
+
console.log(dim(` choose one locally: ${names.map((name) => `hunch provider ${name}`).join(" or ")}`));
|
|
2585
|
+
}
|
|
2586
|
+
else if (resolution.source === "unavailable-preference") {
|
|
2587
|
+
console.log(dim(` ↳ ${resolution.preference} was selected but is unavailable; using the offline heuristic.`));
|
|
2533
2588
|
}
|
|
2534
2589
|
else {
|
|
2535
|
-
console.log(dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence)
|
|
2536
|
-
console.log(dim(`
|
|
2590
|
+
console.log(dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence).`));
|
|
2591
|
+
console.log(dim(` install or log into Claude Code, Codex, or Cursor; then select one with \`hunch provider <name>\`.`));
|
|
2537
2592
|
}
|
|
2538
2593
|
const c = store.reindex().counts;
|
|
2539
2594
|
console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pluggable synthesis provider for the WRITE path (DESIGN.md §4 / §7).
|
|
3
3
|
*
|
|
4
|
-
* LLM synthesis is driven by the user's
|
|
5
|
-
* CLI — never
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* LLM synthesis is driven by the user's chosen coding-assistant subscription
|
|
5
|
+
* CLI — never a pay-per-token API. Claude Code, Codex, and Cursor use different
|
|
6
|
+
* auth surfaces, but every provider returns the same shape. When more than one
|
|
7
|
+
* subscription CLI is available, Hunch deliberately does NOT guess whose plan
|
|
8
|
+
* to spend: the user chooses once with `hunch provider <name>` (stored locally)
|
|
9
|
+
* or overrides per shell with HUNCH_SYNTH_PROVIDER. Ambiguous auto mode stays
|
|
10
|
+
* deterministic and free.
|
|
9
11
|
*
|
|
10
|
-
* Subscription, not API:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the child env (see ClaudeCliProvider.run) to force the CLI down to subscription
|
|
14
|
-
* OAuth / CLAUDE_CODE_OAUTH_TOKEN. There is intentionally NO API-key provider.
|
|
12
|
+
* Subscription, not API: provider-specific API credentials are removed from the
|
|
13
|
+
* child env wherever the CLI would otherwise prefer them. There is intentionally
|
|
14
|
+
* NO direct API-key provider.
|
|
15
15
|
*
|
|
16
16
|
* Every provider returns the same shape so the rest of the system never knows
|
|
17
17
|
* (or cares) which one ran.
|
|
18
18
|
*/
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
20
21
|
import { tmpdir } from "node:os";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
21
24
|
import { summarizeDiff } from "../extractors/diff.js";
|
|
22
25
|
const IS_WIN = process.platform === "win32";
|
|
23
26
|
/**
|
|
@@ -96,6 +99,16 @@ export function pexecIn(cmd, args, opts = {}) {
|
|
|
96
99
|
child.stdin.end();
|
|
97
100
|
});
|
|
98
101
|
}
|
|
102
|
+
/** Every selectable synthesis mode. `auto` is a preference value rather than a
|
|
103
|
+
* provider: it uses a subscription only when exactly one usable CLI is found. */
|
|
104
|
+
export const SYNTH_PROVIDER_NAMES = ["claude-cli", "codex-cli", "cursor-agent", "deterministic"];
|
|
105
|
+
export const SYNTH_PREFERENCES = ["auto", ...SYNTH_PROVIDER_NAMES];
|
|
106
|
+
const PROVIDER_INFO = {
|
|
107
|
+
"claude-cli": { label: "Claude Code", subscription: "Claude subscription" },
|
|
108
|
+
"codex-cli": { label: "Codex", subscription: "ChatGPT subscription" },
|
|
109
|
+
"cursor-agent": { label: "Cursor Agent", subscription: "Cursor subscription" },
|
|
110
|
+
deterministic: { label: "Deterministic local fallback", subscription: null },
|
|
111
|
+
};
|
|
99
112
|
const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
|
|
100
113
|
developer activity (a git commit diff, or a test failure) into a single structured
|
|
101
114
|
"why" record. Be precise and evidence-grounded; never invent facts not supported by
|
|
@@ -169,7 +182,7 @@ const VERIFY_TOOL = {
|
|
|
169
182
|
// --------------------------------------------------------------------------
|
|
170
183
|
// Base for headless-CLI SUBSCRIPTION providers. Each one drives a coding-assistant
|
|
171
184
|
// CLI billed to the user's own subscription (never a pay-per-token API key — see
|
|
172
|
-
//
|
|
185
|
+
// dec_65b058de66). The prompt always goes over STDIN (never argv — keeps untrusted
|
|
173
186
|
// diff content out of any shell pexecIn uses on Windows), and the CLI's text output
|
|
174
187
|
// is handed to the SAME mappers, so the rest of the system is provider-agnostic.
|
|
175
188
|
// --------------------------------------------------------------------------
|
|
@@ -447,9 +460,9 @@ export function extractCodexText(out) {
|
|
|
447
460
|
return agentTexts[agentTexts.length - 1];
|
|
448
461
|
return texts.length ? texts[texts.length - 1] : out;
|
|
449
462
|
}
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
463
|
+
// This registry is deliberately NOT a priority order. Auto mode only spends a
|
|
464
|
+
// subscription when it can identify exactly one usable CLI; see
|
|
465
|
+
// resolveSynthesisProvider below.
|
|
453
466
|
const PROVIDERS = [
|
|
454
467
|
new ClaudeCliProvider(),
|
|
455
468
|
new CodexCliProvider(),
|
|
@@ -457,31 +470,126 @@ const PROVIDERS = [
|
|
|
457
470
|
new DeterministicProvider(),
|
|
458
471
|
];
|
|
459
472
|
// Availability rarely changes within a process (a CLI doesn't get installed mid-run),
|
|
460
|
-
// and
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
const availCache = new Map();
|
|
473
|
+
// and selection runs on every sync/recordFailure. Cache by object identity rather than
|
|
474
|
+
// name so injected test registries never inherit a stale result from another provider.
|
|
475
|
+
const availCache = new WeakMap();
|
|
464
476
|
function isAvailable(p) {
|
|
465
|
-
let v = availCache.get(p
|
|
477
|
+
let v = availCache.get(p);
|
|
466
478
|
if (!v) {
|
|
467
479
|
v = p.available().catch(() => false);
|
|
468
|
-
availCache.set(p
|
|
480
|
+
availCache.set(p, v);
|
|
469
481
|
}
|
|
470
482
|
return v;
|
|
471
483
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
484
|
+
function isSynthPreference(value) {
|
|
485
|
+
return !!value && SYNTH_PREFERENCES.includes(value);
|
|
486
|
+
}
|
|
487
|
+
function fallbackProvider(providers) {
|
|
488
|
+
return providers.find((p) => p.name === "deterministic") ?? new DeterministicProvider();
|
|
489
|
+
}
|
|
490
|
+
function localPreferencePath(root) {
|
|
491
|
+
return join(root, ".hunch", "local.json");
|
|
492
|
+
}
|
|
493
|
+
/** Read a per-user, gitignored choice. Invalid/missing local state is treated as auto;
|
|
494
|
+
* `writeSynthesisPreference` refuses to overwrite malformed data so this forgiveness
|
|
495
|
+
* never destroys someone else's local settings. */
|
|
496
|
+
export function readSynthesisPreference(root) {
|
|
497
|
+
try {
|
|
498
|
+
const file = localPreferencePath(root);
|
|
499
|
+
if (!existsSync(file))
|
|
500
|
+
return "auto";
|
|
501
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
502
|
+
return typeof parsed.synthProvider === "string" && isSynthPreference(parsed.synthProvider)
|
|
503
|
+
? parsed.synthProvider
|
|
504
|
+
: "auto";
|
|
479
505
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
506
|
+
catch {
|
|
507
|
+
return "auto";
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/** Persist the user's provider choice only in `.hunch/local.json`, which is never a
|
|
511
|
+
* repository policy. That means each developer controls their own subscription spend. */
|
|
512
|
+
export function writeSynthesisPreference(root, preference) {
|
|
513
|
+
if (!isSynthPreference(preference))
|
|
514
|
+
throw new Error(`unknown synthesis provider preference: ${preference}`);
|
|
515
|
+
const file = localPreferencePath(root);
|
|
516
|
+
let local = {};
|
|
517
|
+
if (existsSync(file)) {
|
|
518
|
+
const raw = readFileSync(file, "utf8");
|
|
519
|
+
if (raw.trim()) {
|
|
520
|
+
try {
|
|
521
|
+
const parsed = JSON.parse(raw);
|
|
522
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object")
|
|
523
|
+
throw new Error("not an object");
|
|
524
|
+
local = parsed;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
throw new Error(`refusing to overwrite malformed local configuration: ${file}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
532
|
+
writeFileAtomic(file, `${JSON.stringify({ ...local, synthProvider: preference }, null, 2)}\n`);
|
|
533
|
+
}
|
|
534
|
+
async function statusesFor(providers) {
|
|
535
|
+
const statuses = [];
|
|
536
|
+
for (const provider of providers) {
|
|
537
|
+
if (!SYNTH_PROVIDER_NAMES.includes(provider.name))
|
|
538
|
+
continue;
|
|
539
|
+
const name = provider.name;
|
|
540
|
+
const info = PROVIDER_INFO[name];
|
|
541
|
+
statuses.push({ name, ...info, available: await isAvailable(provider) });
|
|
542
|
+
}
|
|
543
|
+
return statuses;
|
|
544
|
+
}
|
|
545
|
+
/** Resolve the provider without ever inferring which of several installed products is
|
|
546
|
+
* the one the user intends to spend. Precedence is deliberate: a one-shell override,
|
|
547
|
+
* then a per-user local preference, then safe auto-detection. */
|
|
548
|
+
export async function resolveSynthesisProvider(opts = {}) {
|
|
549
|
+
const providers = opts.providers ?? PROVIDERS;
|
|
550
|
+
const env = opts.env ?? process.env;
|
|
551
|
+
const statuses = await statusesFor(providers);
|
|
552
|
+
const fallback = fallbackProvider(providers);
|
|
553
|
+
const find = (name) => providers.find((p) => p.name === name);
|
|
554
|
+
const usable = async (name) => {
|
|
555
|
+
const provider = find(name);
|
|
556
|
+
return provider && await isAvailable(provider) ? provider : undefined;
|
|
557
|
+
};
|
|
558
|
+
const environment = env.HUNCH_SYNTH_PROVIDER?.trim();
|
|
559
|
+
if (environment && isSynthPreference(environment) && environment !== "auto") {
|
|
560
|
+
const selected = await usable(environment);
|
|
561
|
+
if (selected)
|
|
562
|
+
return { provider: selected, source: "environment", preference: environment, statuses };
|
|
563
|
+
return { provider: fallback, source: "unavailable-preference", preference: environment, statuses };
|
|
564
|
+
}
|
|
565
|
+
// `HUNCH_SYNTH_PROVIDER=auto` is useful in CI or a shell profile: it explicitly
|
|
566
|
+
// suppresses the local preference and re-enters the safe auto policy.
|
|
567
|
+
const preference = environment === "auto"
|
|
568
|
+
? "auto"
|
|
569
|
+
: opts.root ? readSynthesisPreference(opts.root) : "auto";
|
|
570
|
+
if (preference !== "auto") {
|
|
571
|
+
const selected = await usable(preference);
|
|
572
|
+
if (selected)
|
|
573
|
+
return { provider: selected, source: "local", preference, statuses };
|
|
574
|
+
return { provider: fallback, source: "unavailable-preference", preference, statuses };
|
|
575
|
+
}
|
|
576
|
+
const available = statuses.filter((status) => status.name !== "deterministic" && status.available);
|
|
577
|
+
if (available.length === 1) {
|
|
578
|
+
const selected = await usable(available[0].name);
|
|
579
|
+
if (selected)
|
|
580
|
+
return { provider: selected, source: "single-available", preference, statuses };
|
|
483
581
|
}
|
|
484
|
-
return
|
|
582
|
+
return {
|
|
583
|
+
provider: fallback,
|
|
584
|
+
source: available.length > 1 ? "ambiguous" : "none",
|
|
585
|
+
preference,
|
|
586
|
+
statuses,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** The provider used by normal synthesis. See `resolveSynthesisProvider` for a
|
|
590
|
+
* diagnosable result with the selection source and every candidate's availability. */
|
|
591
|
+
export async function selectProvider(opts = {}) {
|
|
592
|
+
return (await resolveSynthesisProvider(opts)).provider;
|
|
485
593
|
}
|
|
486
594
|
// ---- Deep Synthesis: ensemble of subscription CLIs ------------------------
|
|
487
595
|
// Opt-in (backfill/sync --deep): fan a commit out to EVERY available subscription
|
|
@@ -490,9 +598,9 @@ export async function selectProvider() {
|
|
|
490
598
|
// the guard path; confidence is capped below the strict gate so output stays advisory.
|
|
491
599
|
/** All available subscription-CLI workers (claude/codex/cursor), excluding the
|
|
492
600
|
* deterministic fallback — the pool Deep Synthesis fans a commit out to. */
|
|
493
|
-
export async function selectWorkers() {
|
|
601
|
+
export async function selectWorkers(opts = {}) {
|
|
494
602
|
const out = [];
|
|
495
|
-
for (const p of PROVIDERS) {
|
|
603
|
+
for (const p of opts.providers ?? PROVIDERS) {
|
|
496
604
|
if (p.name === "deterministic")
|
|
497
605
|
continue; // workers are real subscription CLIs only
|
|
498
606
|
if (await isAvailable(p))
|
|
@@ -592,7 +700,7 @@ export class EnsembleProvider {
|
|
|
592
700
|
* (the caller then falls back to the normal single-provider path). `samples` sets
|
|
593
701
|
* the self-consistency depth for the single-CLI case. */
|
|
594
702
|
export async function selectEnsemble(opts = {}) {
|
|
595
|
-
const workers = await selectWorkers();
|
|
703
|
+
const workers = await selectWorkers(opts);
|
|
596
704
|
// The self-consistency policy default (DEFAULT_SAMPLES) is applied HERE, not in the
|
|
597
705
|
// provider — so a single CLI under --deep is sampled N times, while direct
|
|
598
706
|
// construction stays passthrough. `--samples 1` opts back out.
|
|
@@ -601,9 +709,9 @@ export async function selectEnsemble(opts = {}) {
|
|
|
601
709
|
/** Pick a CLI provider to run the Critic pass (subscription-only, like the workers).
|
|
602
710
|
* Returns null when no assistant CLI is installed — verification then no-ops and the
|
|
603
711
|
* un-audited draft stands (graceful degradation; dec_18a81c8291). */
|
|
604
|
-
export async function selectVerifier() {
|
|
605
|
-
const
|
|
606
|
-
return
|
|
712
|
+
export async function selectVerifier(opts = {}) {
|
|
713
|
+
const { provider } = await resolveSynthesisProvider(opts);
|
|
714
|
+
return provider.name === "deterministic" ? null : provider;
|
|
607
715
|
}
|
|
608
716
|
// ---- Verification (the Critic pass) ---------------------------------------
|
|
609
717
|
// Audit a draft against the commit it came from, then PRUNE unsupported
|
|
@@ -98,9 +98,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
98
98
|
const provider = localOnly
|
|
99
99
|
? new DeterministicProvider()
|
|
100
100
|
: opts.deep
|
|
101
|
-
? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider()
|
|
101
|
+
? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider({ root })
|
|
102
102
|
: opts.force || opts.verify || isSignificant(meta, analysis, codeFiles)
|
|
103
|
-
? await selectProvider()
|
|
103
|
+
? await selectProvider({ root })
|
|
104
104
|
: new DeterministicProvider();
|
|
105
105
|
const input = { subject: meta.subject, body: meta.body, files: codeFiles, diff, analysis };
|
|
106
106
|
let draft = await draftDecisionSafe(provider, input);
|
|
@@ -108,7 +108,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
108
108
|
// (BEFORE they scaffold tripwires below) and consequences, and lower confidence on weak
|
|
109
109
|
// grounding. No-ops when no assistant CLI is available; never raises trust (dec_9a2f2fe72a).
|
|
110
110
|
if (wantVerify)
|
|
111
|
-
draft = await verifyDecisionSafe(await selectVerifier(), input, draft);
|
|
111
|
+
draft = await verifyDecisionSafe(await selectVerifier({ root }), input, draft);
|
|
112
112
|
// Advisory synthesis telemetry for `hunch review` — which provider ran, how many drafts
|
|
113
113
|
// were reconciled, their agreement, and the verifier's grounding. Rides in `evidence`
|
|
114
114
|
// (no schema change → respects forward-migration invariant con_947c578b2c).
|
|
@@ -199,7 +199,7 @@ export async function recordFailure(store, root, failure, opts = {}) {
|
|
|
199
199
|
// A private bug may contain a stack trace, customer data, or secrets. Keep the
|
|
200
200
|
// whole capture local unless the caller deliberately routes it through a shared
|
|
201
201
|
// (non-private) workflow.
|
|
202
|
-
const provider = opts.private ? new DeterministicProvider() : await selectProvider();
|
|
202
|
+
const provider = opts.private ? new DeterministicProvider() : await selectProvider({ root });
|
|
203
203
|
const input = {
|
|
204
204
|
test: failure.test,
|
|
205
205
|
message: failure.message,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Antigravity, Codex).",
|