@davesheffer/hunch 1.8.0 → 1.8.2

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.
@@ -2,10 +2,11 @@
2
2
  * Pluggable synthesis provider for the WRITE path (DESIGN.md §4 / §7).
3
3
  *
4
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)
5
+ * CLI or an explicitly configured OpenAI-compatible endpoint. Claude Code,
6
+ * Codex, and Cursor use different auth surfaces, but every provider returns the
7
+ * same shape. When more than one non-deterministic provider is available, Hunch
8
+ * deliberately does NOT guess which one to use: the user chooses once with
9
+ * `hunch provider <name>` (stored locally)
9
10
  * or overrides per shell with HUNCH_SYNTH_PROVIDER. Ambiguous auto mode stays
10
11
  * deterministic and free.
11
12
  *
@@ -13,11 +14,19 @@
13
14
  * child env wherever the CLI would otherwise prefer them. There is intentionally
14
15
  * NO direct API-key provider.
15
16
  *
17
+ * A fourth, OPT-IN provider (name "openai-compat", alias "ollama") speaks the
18
+ * OpenAI chat-completions format over HTTP to a self-hosted endpoint instead of a
19
+ * subscription CLI. It stays off unless HUNCH_SYNTH_BASE_URL and HUNCH_SYNTH_MODEL
20
+ * are both explicitly set. Local/LAN endpoints work directly; every public remote
21
+ * requires HUNCH_SYNTH_ALLOW_METERED=1 because billing cannot be inferred safely
22
+ * from a hostname — con_2ce3f2a547's spirit is "never silently bill."
23
+ *
16
24
  * Every provider returns the same shape so the rest of the system never knows
17
25
  * (or cares) which one ran.
18
26
  */
19
27
  import { spawn } from "node:child_process";
20
28
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
29
+ import { isIP } from "node:net";
21
30
  import { tmpdir } from "node:os";
22
31
  import { dirname, join } from "node:path";
23
32
  import { writeFileAtomic } from "../core/io.js";
@@ -100,13 +109,16 @@ export function pexecIn(cmd, args, opts = {}) {
100
109
  });
101
110
  }
102
111
  /** 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"];
112
+ * provider: it uses a subscription only when exactly one usable CLI is found.
113
+ * "openai-compat" is the opt-in local/self-hosted HTTP provider (Ollama, vLLM,
114
+ * LM Studio, ...) — not a subscription, but explicitly selectable like one. */
115
+ export const SYNTH_PROVIDER_NAMES = ["claude-cli", "codex-cli", "cursor-agent", "openai-compat", "deterministic"];
105
116
  export const SYNTH_PREFERENCES = ["auto", ...SYNTH_PROVIDER_NAMES];
106
117
  const PROVIDER_INFO = {
107
118
  "claude-cli": { label: "Claude Code", subscription: "Claude subscription" },
108
119
  "codex-cli": { label: "Codex", subscription: "ChatGPT subscription" },
109
120
  "cursor-agent": { label: "Cursor Agent", subscription: "Cursor subscription" },
121
+ "openai-compat": { label: "Self-hosted / local model (Ollama, vLLM, LM Studio, ...)", subscription: null },
110
122
  deterministic: { label: "Deterministic local fallback", subscription: null },
111
123
  };
112
124
  const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
@@ -179,14 +191,7 @@ const VERIFY_TOOL = {
179
191
  required: ["grounded", "unsupported_alternatives", "unsupported_claims"],
180
192
  },
181
193
  };
182
- // --------------------------------------------------------------------------
183
- // Base for headless-CLI SUBSCRIPTION providers. Each one drives a coding-assistant
184
- // CLI billed to the user's own subscription (never a pay-per-token API key — see
185
- // dec_65b058de66). The prompt always goes over STDIN (never argv — keeps untrusted
186
- // diff content out of any shell pexecIn uses on Windows), and the CLI's text output
187
- // is handed to the SAME mappers, so the rest of the system is provider-agnostic.
188
- // --------------------------------------------------------------------------
189
- class CliSynthProvider {
194
+ class PromptSynthProvider {
190
195
  /** Run a CLI with the prompt on stdin, stripping API-key env vars so the tool
191
196
  * falls through to its SUBSCRIPTION credentials. Shared by codex/cursor. */
192
197
  async runCli(bin, args, stripEnv, prompt, timeoutMs = 120_000) {
@@ -203,7 +208,7 @@ class CliSynthProvider {
203
208
  return stdout;
204
209
  }
205
210
  async draftDecision(input) {
206
- const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`);
211
+ const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`, "json");
207
212
  const draft = decisionDraftFromText(text, input.subject);
208
213
  // No usable LLM JSON (truncation, refusal, prose-only, or a CLI whose output
209
214
  // shape we misread) → THROW so the safe wrapper falls back to the deterministic
@@ -218,37 +223,37 @@ class CliSynthProvider {
218
223
  return draft;
219
224
  }
220
225
  async draftBug(input) {
221
- const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`);
226
+ const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`, "json");
222
227
  const draft = bugDraftFromText(text, input.test, input.message);
223
228
  if (!draft)
224
229
  throw new Error(`${this.name}: no usable bug JSON in output`);
225
230
  return draft;
226
231
  }
227
- /** Grounded prose for the wiki. Same subscription-only run() path (API keys
228
- * stripped). Throws on empty output so the caller falls back to its
229
- * deterministic template page. */
232
+ /** Grounded prose for the wiki. Uses text mode rather than the structured JSON
233
+ * mode required by the record mappers. Throws on empty output so the caller
234
+ * falls back to its deterministic template page. */
230
235
  async draftProse(prompt) {
231
- const text = (await this.run(prompt)).trim();
236
+ const text = (await this.run(prompt, "text")).trim();
232
237
  if (!text)
233
238
  throw new Error(`${this.name}: empty prose output`);
234
239
  return text;
235
240
  }
236
- /** The Critic pass: audit a draft against its commit. Same subscription-only
237
- * run() path (API keys stripped), so this never bills the pay-per-token API.
241
+ /** The Critic pass: audit a draft against its commit through the provider's
242
+ * guarded transport.
238
243
  * Throws on unusable output so verifyDecisionSafe degrades to the un-audited
239
244
  * draft (a verifier failure must never lose the draft — dec_18a81c8291). */
240
245
  async verifyDecision(input, draft) {
241
- const text = await this.run(`${VERIFY_SYSTEM}\n\n${verifyPrompt(input, draft)}\n\n${jsonInstruction(VERIFY_TOOL.input_schema)}`);
246
+ const text = await this.run(`${VERIFY_SYSTEM}\n\n${verifyPrompt(input, draft)}\n\n${jsonInstruction(VERIFY_TOOL.input_schema)}`, "json");
242
247
  const verdict = verdictFromText(text);
243
248
  if (!verdict)
244
249
  throw new Error(`${this.name}: no usable verdict JSON in output`);
245
250
  return verdict;
246
251
  }
247
252
  /** Judge whether an auto-drafted decision is worth keeping (for auto-review).
248
- * Same subscription-only run() path (API keys stripped). Throws on unusable
253
+ * Uses the provider's guarded transport. Throws on unusable
249
254
  * output so the caller can degrade to a keep-for-human verdict. */
250
255
  async judgeDraft(draft, existing) {
251
- const text = await this.run(`${RELEVANCE_SYSTEM}\n\n${relevancePrompt(draft, existing)}\n\n${jsonInstruction(RELEVANCE_TOOL.input_schema)}`);
256
+ const text = await this.run(`${RELEVANCE_SYSTEM}\n\n${relevancePrompt(draft, existing)}\n\n${jsonInstruction(RELEVANCE_TOOL.input_schema)}`, "json");
252
257
  const verdict = relevanceFromText(text);
253
258
  if (!verdict)
254
259
  throw new Error(`${this.name}: no usable relevance JSON in output`);
@@ -267,10 +272,28 @@ const MODEL_RE = /^[A-Za-z0-9._:/-]+$/;
267
272
  export function safeModel(v, fallback) {
268
273
  return v && MODEL_RE.test(v) ? v : fallback;
269
274
  }
275
+ // A timeout comes from a HUNCH_*_TIMEOUT_MS env var and feeds AbortController's
276
+ // delay directly (never a shell argv token, unlike safeModel's model id) — but a
277
+ // non-numeric or nonsensical value (negative, zero, NaN, Infinity) would either
278
+ // abort immediately or never abort at all, so validate the same way: fall back to
279
+ // the provider's default rather than propagate garbage.
280
+ export function safeTimeout(v, fallback) {
281
+ const n = Number(v);
282
+ return v && Number.isFinite(n) && n > 0 ? n : fallback;
283
+ }
284
+ // max_tokens caps OUTPUT length (never a shell argv token, unlike safeModel's
285
+ // model id) — same failure modes as a timeout, so validate the same way: fall
286
+ // back to a safe default rather than propagate garbage into the request body
287
+ // (issue #11; orthogonal to the context-window/truncation problem that issue
288
+ // is mainly about — this only bounds how much the model is allowed to WRITE).
289
+ export function safeMaxTokens(v, fallback) {
290
+ const n = Number(v);
291
+ return v && Number.isFinite(n) && n > 0 ? n : fallback;
292
+ }
270
293
  // --------------------------------------------------------------------------
271
294
  // Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
272
295
  // --------------------------------------------------------------------------
273
- class ClaudeCliProvider extends CliSynthProvider {
296
+ class ClaudeCliProvider extends PromptSynthProvider {
274
297
  name = "claude-cli";
275
298
  // Default to the `haiku` alias (cheap/fast, and survives model retirements)
276
299
  // rather than a pinned dated id; override with HUNCH_SYNTH_MODEL if needed.
@@ -330,7 +353,7 @@ class ClaudeCliProvider extends CliSynthProvider {
330
353
  // --------------------------------------------------------------------------
331
354
  // Provider B1: OpenAI Codex CLI (`codex exec`) — billed to the ChatGPT subscription
332
355
  // --------------------------------------------------------------------------
333
- class CodexCliProvider extends CliSynthProvider {
356
+ class CodexCliProvider extends PromptSynthProvider {
334
357
  name = "codex-cli";
335
358
  model = safeModel(process.env.HUNCH_CODEX_MODEL, undefined); // omit → codex uses its configured default
336
359
  async available() {
@@ -354,7 +377,7 @@ class CodexCliProvider extends CliSynthProvider {
354
377
  // --------------------------------------------------------------------------
355
378
  // Provider B2: Cursor Agent CLI (`cursor-agent -p`) — billed to the Cursor subscription
356
379
  // --------------------------------------------------------------------------
357
- class CursorCliProvider extends CliSynthProvider {
380
+ class CursorCliProvider extends PromptSynthProvider {
358
381
  name = "cursor-agent";
359
382
  model = safeModel(process.env.HUNCH_CURSOR_MODEL, undefined);
360
383
  async available() {
@@ -377,6 +400,174 @@ class CursorCliProvider extends CliSynthProvider {
377
400
  }
378
401
  }
379
402
  // --------------------------------------------------------------------------
403
+ // Provider D: OpenAI-compatible / local model endpoint (Ollama, vLLM, LM
404
+ // Studio, llama.cpp server, ...) — opt-in, NOT a subscription CLI. Speaks the
405
+ // OpenAI chat-completions wire format over HTTP, so ONE implementation covers
406
+ // any self-hosted server that implements it (Ollama's /v1 compatibility layer
407
+ // included — no separate native /api/chat client). Off by default: available()
408
+ // requires BOTH HUNCH_SYNTH_BASE_URL and HUNCH_SYNTH_MODEL, so an installation
409
+ // with neither set behaves exactly as it did before this provider existed.
410
+ //
411
+ // Exported (unlike the CLI providers) so tests can construct fresh instances and
412
+ // read process.env at CALL time — see run()/available() below, which read env
413
+ // vars directly rather than caching them in constructor fields. That mirrors
414
+ // selectProvider()'s own style (it re-reads HUNCH_SYNTH_PROVIDER on every call)
415
+ // and avoids a stale-field trap: a module-level PROVIDERS singleton constructed
416
+ // once at import time would otherwise never see env vars a test (or a long-lived
417
+ // process) sets afterward.
418
+ // --------------------------------------------------------------------------
419
+ // con_2ce3f2a547's boundary is "never silently bill." A denylist cannot enforce
420
+ // that boundary: new OpenAI-compatible paid providers appear continually, and a
421
+ // fully-qualified trailing DNS dot can even evade a naive exact-host comparison.
422
+ // Fail closed instead. Loopback, private/link-local IPs, and conventional LAN DNS
423
+ // names work without ceremony; every public remote requires the deliberate,
424
+ // named HUNCH_SYNTH_ALLOW_METERED=1 opt-in. Publicly hosted self-managed servers
425
+ // use that same flag because billing cannot be inferred reliably from a hostname.
426
+ function normalizedHostname(url) {
427
+ return url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
428
+ }
429
+ /** Parse the exact base-URL shape this provider can safely compose with
430
+ * `/chat/completions`. Credentials belong in HUNCH_SYNTH_API_KEY; query strings
431
+ * and fragments are rejected because appending a path to either is ambiguous. */
432
+ function parseOpenAICompatBaseUrl(baseUrl) {
433
+ try {
434
+ const url = new URL(baseUrl);
435
+ if (url.protocol !== "http:" && url.protocol !== "https:")
436
+ return null;
437
+ if (!url.hostname || url.username || url.password || url.search || url.hash)
438
+ return null;
439
+ return url;
440
+ }
441
+ catch {
442
+ return null;
443
+ }
444
+ }
445
+ function isPrivateIpv4(hostname) {
446
+ const [a = -1, b = -1] = hostname.split(".").map(Number);
447
+ return a === 0
448
+ || a === 10
449
+ || a === 127
450
+ || (a === 100 && b >= 64 && b <= 127) // shared space, including common tailnets
451
+ || (a === 169 && b === 254)
452
+ || (a === 172 && b >= 16 && b <= 31)
453
+ || (a === 192 && b === 168);
454
+ }
455
+ function isPrivateIpv6(hostname) {
456
+ const host = hostname.toLowerCase();
457
+ if (host === "::" || host === "::1")
458
+ return true;
459
+ if (host.startsWith("::ffff:")) {
460
+ const mapped = host.slice("::ffff:".length);
461
+ return isIP(mapped) === 4 && isPrivateIpv4(mapped);
462
+ }
463
+ const first = host.split(":", 1)[0] ?? "";
464
+ return first.startsWith("fc")
465
+ || first.startsWith("fd")
466
+ || /^fe[89ab]/.test(first);
467
+ }
468
+ function isLocalOrPrivateHost(hostname) {
469
+ if (isIP(hostname) === 4)
470
+ return isPrivateIpv4(hostname);
471
+ if (isIP(hostname) === 6)
472
+ return isPrivateIpv6(hostname);
473
+ if (hostname === "localhost" || !hostname.includes("."))
474
+ return true;
475
+ return [".localhost", ".local", ".lan", ".internal", ".home.arpa"].some((suffix) => hostname.endsWith(suffix));
476
+ }
477
+ function requiresMeteredOptIn(url) {
478
+ return !isLocalOrPrivateHost(normalizedHostname(url));
479
+ }
480
+ export function meteredHostsAllowed(env = process.env) {
481
+ return env.HUNCH_SYNTH_ALLOW_METERED === "1";
482
+ }
483
+ export class OpenAICompatProvider extends PromptSynthProvider {
484
+ name = "openai-compat";
485
+ async available() {
486
+ const baseUrl = process.env.HUNCH_SYNTH_BASE_URL;
487
+ const endpoint = baseUrl ? parseOpenAICompatBaseUrl(baseUrl) : null;
488
+ if (!endpoint || !safeModel(process.env.HUNCH_SYNTH_MODEL, undefined))
489
+ return false;
490
+ return meteredHostsAllowed() || !requiresMeteredOptIn(endpoint);
491
+ }
492
+ async run(prompt, output = "json") {
493
+ const baseUrl = process.env.HUNCH_SYNTH_BASE_URL;
494
+ const model = safeModel(process.env.HUNCH_SYNTH_MODEL, undefined);
495
+ if (!baseUrl || !model)
496
+ throw new Error("openai-compat: HUNCH_SYNTH_BASE_URL/HUNCH_SYNTH_MODEL not set");
497
+ const endpoint = parseOpenAICompatBaseUrl(baseUrl);
498
+ if (!endpoint) {
499
+ throw new Error("openai-compat: HUNCH_SYNTH_BASE_URL must be an http(s) base URL without credentials, a query, or a fragment");
500
+ }
501
+ if (requiresMeteredOptIn(endpoint) && !meteredHostsAllowed()) {
502
+ throw new Error(`openai-compat: refusing to call public remote ${normalizedHostname(endpoint)} — it may be metered, and con_2ce3f2a547 blocks silent pay-per-token billing. Set HUNCH_SYNTH_ALLOW_METERED=1 if this is deliberate.`);
503
+ }
504
+ endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, "")}/chat/completions`;
505
+ const apiKey = process.env.HUNCH_SYNTH_API_KEY;
506
+ const timeoutMs = safeTimeout(process.env.HUNCH_SYNTH_TIMEOUT_MS, 300_000);
507
+ const controller = new AbortController();
508
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
509
+ try {
510
+ const res = await fetch(endpoint, {
511
+ method: "POST",
512
+ headers: {
513
+ "content-type": "application/json",
514
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
515
+ },
516
+ body: JSON.stringify({
517
+ model,
518
+ messages: [{ role: "user", content: prompt }],
519
+ ...(output === "json" ? { response_format: { type: "json_object" } } : {}),
520
+ stream: false,
521
+ max_tokens: safeMaxTokens(process.env.HUNCH_SYNTH_MAX_TOKENS, 2048),
522
+ }),
523
+ signal: controller.signal,
524
+ });
525
+ if (!res.ok) {
526
+ throw new Error(`openai-compat endpoint returned ${res.status}: ${(await res.text()).slice(0, 300)}`);
527
+ }
528
+ const body = (await res.json());
529
+ const content = body.choices?.[0]?.message?.content;
530
+ if (!content)
531
+ throw new Error("openai-compat endpoint returned no message content");
532
+ return content;
533
+ }
534
+ finally {
535
+ clearTimeout(timer);
536
+ }
537
+ }
538
+ }
539
+ /** Best-effort: does the configured openai-compat endpoint look like Ollama with
540
+ * an UNSET num_ctx? Returns
541
+ * an advisory warning string when so, or null when the endpoint isn't reachable,
542
+ * doesn't look like Ollama's /api/show shape, or already has num_ctx set — this
543
+ * is diagnostics only, never thrown, never blocking. Deliberately does NOT try to
544
+ * report the model's effective context length: modern Ollama defaults may come
545
+ * from server configuration or VRAM tiers, and model_info keys are not a stable
546
+ * parse target. We therefore report only the observed fact — whether num_ctx is
547
+ * pinned in the model — without guessing an effective token count. */
548
+ export async function probeOllamaNumCtx(baseUrl, model) {
549
+ try {
550
+ const root = baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
551
+ const res = await fetch(`${root}/api/show`, {
552
+ method: "POST",
553
+ headers: { "content-type": "application/json" },
554
+ body: JSON.stringify({ name: model }),
555
+ signal: AbortSignal.timeout(5_000),
556
+ });
557
+ if (!res.ok)
558
+ return null;
559
+ const body = (await res.json());
560
+ if (typeof body.parameters !== "string")
561
+ return null;
562
+ if (/^num_ctx\s+\d+/m.test(body.parameters))
563
+ return null; // already configured — nothing to warn about
564
+ return "⚠ This Ollama model does not pin num_ctx; its effective context depends on server/VRAM defaults. For stable large-diff synthesis, see https://hunch-pi.vercel.app/cookbook and pin num_ctx via a custom Modelfile.";
565
+ }
566
+ catch {
567
+ return null; // not Ollama, unreachable, or an unexpected response shape — advisory only, never throw
568
+ }
569
+ }
570
+ // --------------------------------------------------------------------------
380
571
  // Provider C: deterministic fallback (no LLM, always available)
381
572
  // --------------------------------------------------------------------------
382
573
  export class DeterministicProvider {
@@ -467,12 +658,16 @@ const PROVIDERS = [
467
658
  new ClaudeCliProvider(),
468
659
  new CodexCliProvider(),
469
660
  new CursorCliProvider(),
661
+ new OpenAICompatProvider(),
470
662
  new DeterministicProvider(),
471
663
  ];
472
664
  // Availability rarely changes within a process (a CLI doesn't get installed mid-run),
473
665
  // and selection runs on every sync/recordFailure. Cache by object identity rather than
474
666
  // name so injected test registries never inherit a stale result from another provider.
475
- const availCache = new WeakMap();
667
+ // A plain Map (not WeakMap): __resetAvailabilityCacheForTests below needs .clear(),
668
+ // which WeakMap doesn't support — the module's singleton PROVIDERS array is the only
669
+ // thing that ever populates this in production, so there's no unbounded-growth risk.
670
+ const availCache = new Map();
476
671
  function isAvailable(p) {
477
672
  let v = availCache.get(p);
478
673
  if (!v) {
@@ -481,8 +676,27 @@ function isAvailable(p) {
481
676
  }
482
677
  return v;
483
678
  }
679
+ /** Test-only: clears the availability memoization cache so a test that toggles
680
+ * env vars mid-process (e.g. HUNCH_SYNTH_BASE_URL) isn't served a stale result
681
+ * cached by an earlier call in the same process. Never call from production code. */
682
+ export function __resetAvailabilityCacheForTests() {
683
+ availCache.clear();
684
+ }
685
+ /** "ollama" is accepted as an alias for "openai-compat" — the provider is not
686
+ * Ollama-specific (it speaks the OpenAI chat-completions format any self-hosted
687
+ * server can implement), but Ollama is the most common self-hosted target and
688
+ * users reach for that name first. Applied to the HUNCH_SYNTH_PROVIDER env var in
689
+ * resolveSynthesisProvider below, and exported so the `hunch provider <name>` CLI
690
+ * command (index.ts) normalizes it the same way before validating/persisting a
691
+ * local preference — the two paths must agree, or a user who sets one and reads
692
+ * the other back gets a confusing "unknown provider" message for a name that
693
+ * actually works. */
694
+ export function normalizeProviderName(v) {
695
+ return v === "ollama" ? "openai-compat" : v;
696
+ }
484
697
  function isSynthPreference(value) {
485
- return !!value && SYNTH_PREFERENCES.includes(value);
698
+ const normalized = normalizeProviderName(value);
699
+ return !!normalized && SYNTH_PREFERENCES.includes(normalized);
486
700
  }
487
701
  function fallbackProvider(providers) {
488
702
  return providers.find((p) => p.name === "deterministic") ?? new DeterministicProvider();
@@ -499,9 +713,8 @@ export function readSynthesisPreference(root) {
499
713
  if (!existsSync(file))
500
714
  return "auto";
501
715
  const parsed = JSON.parse(readFileSync(file, "utf8"));
502
- return typeof parsed.synthProvider === "string" && isSynthPreference(parsed.synthProvider)
503
- ? parsed.synthProvider
504
- : "auto";
716
+ const normalized = typeof parsed.synthProvider === "string" ? normalizeProviderName(parsed.synthProvider) : undefined;
717
+ return isSynthPreference(normalized) ? normalized : "auto";
505
718
  }
506
719
  catch {
507
720
  return "auto";
@@ -510,7 +723,8 @@ export function readSynthesisPreference(root) {
510
723
  /** Persist the user's provider choice only in `.hunch/local.json`, which is never a
511
724
  * repository policy. That means each developer controls their own subscription spend. */
512
725
  export function writeSynthesisPreference(root, preference) {
513
- if (!isSynthPreference(preference))
726
+ const normalized = normalizeProviderName(preference);
727
+ if (!isSynthPreference(normalized))
514
728
  throw new Error(`unknown synthesis provider preference: ${preference}`);
515
729
  const file = localPreferencePath(root);
516
730
  let local = {};
@@ -529,7 +743,7 @@ export function writeSynthesisPreference(root, preference) {
529
743
  }
530
744
  }
531
745
  mkdirSync(dirname(file), { recursive: true });
532
- writeFileAtomic(file, `${JSON.stringify({ ...local, synthProvider: preference }, null, 2)}\n`);
746
+ writeFileAtomic(file, `${JSON.stringify({ ...local, synthProvider: normalized }, null, 2)}\n`);
533
747
  }
534
748
  async function statusesFor(providers) {
535
749
  const statuses = [];
@@ -555,7 +769,7 @@ export async function resolveSynthesisProvider(opts = {}) {
555
769
  const provider = find(name);
556
770
  return provider && await isAvailable(provider) ? provider : undefined;
557
771
  };
558
- const environment = env.HUNCH_SYNTH_PROVIDER?.trim();
772
+ const environment = normalizeProviderName(env.HUNCH_SYNTH_PROVIDER?.trim());
559
773
  if (environment && isSynthPreference(environment) && environment !== "auto") {
560
774
  const selected = await usable(environment);
561
775
  if (selected)
@@ -591,18 +805,22 @@ export async function resolveSynthesisProvider(opts = {}) {
591
805
  export async function selectProvider(opts = {}) {
592
806
  return (await resolveSynthesisProvider(opts)).provider;
593
807
  }
594
- // ---- Deep Synthesis: ensemble of subscription CLIs ------------------------
595
- // Opt-in (backfill/sync --deep): fan a commit out to EVERY available subscription
596
- // CLI, drop failures, and reconcile the drafts. Subscription-only (the workers are
597
- // the same CLI providers, so ANTHROPIC_API_KEY stripping is inherited). NEVER used on
598
- // the guard path; confidence is capped below the strict gate so output stays advisory.
599
- /** All available subscription-CLI workers (claude/codex/cursor), excluding the
600
- * deterministic fallback the pool Deep Synthesis fans a commit out to. */
808
+ // ---- Deep Synthesis: ensemble of subscription CLIs (+ opt-in openai-compat) ----
809
+ // Opt-in (backfill/sync --deep): fan a commit out to EVERY available worker —
810
+ // the subscription CLIs (ANTHROPIC_API_KEY stripping inherited from them) plus the
811
+ // opt-in openai-compat HTTP provider when configured, which is outside that
812
+ // stripping scope entirely (con_2ce3f2a547 governs the Anthropic API specifically,
813
+ // not a user-configured self-hosted endpoint) — drop failures, reconcile the
814
+ // drafts. NEVER used on the guard path; confidence is capped below the strict gate
815
+ // so output stays advisory.
816
+ /** All available subscription-CLI workers (claude/codex/cursor, plus the opt-in
817
+ * openai-compat), excluding the deterministic fallback — the pool Deep Synthesis
818
+ * fans a commit out to. */
601
819
  export async function selectWorkers(opts = {}) {
602
820
  const out = [];
603
821
  for (const p of opts.providers ?? PROVIDERS) {
604
822
  if (p.name === "deterministic")
605
- continue; // workers are real subscription CLIs only
823
+ continue; // workers are real LLM providers only
606
824
  if (await isAvailable(p))
607
825
  out.push(p);
608
826
  }
@@ -650,8 +868,8 @@ export function mergeDecisionDrafts(drafts) {
650
868
  agreement: Math.round(agreement * 100) / 100,
651
869
  };
652
870
  }
653
- // Default self-consistency depth when only ONE subscription CLI is installed (the
654
- // common case): sample it this many times and reconcile, so single-CLI users get
871
+ // Default self-consistency depth when only ONE LLM provider is available (the
872
+ // common case): sample it this many times and reconcile, so single-provider users get
655
873
  // ensemble-like robustness. Tunable per-call via `--samples`.
656
874
  const DEFAULT_SAMPLES = 2;
657
875
  export class EnsembleProvider {
@@ -678,7 +896,7 @@ export class EnsembleProvider {
678
896
  }
679
897
  async draftDecision(input) {
680
898
  if (!this.workers.length)
681
- throw new Error("ensemble: no subscription CLI workers available");
899
+ throw new Error("ensemble: no LLM provider workers available");
682
900
  const settled = await Promise.allSettled(this.decisionTasks(input).map((t) => t()));
683
901
  const drafts = settled.flatMap((s) => (s.status === "fulfilled" ? [s.value] : []));
684
902
  if (!drafts.length)
@@ -696,9 +914,9 @@ export class EnsembleProvider {
696
914
  throw new Error("ensemble: all workers failed for bug");
697
915
  }
698
916
  }
699
- /** Build the Deep-Synthesis provider, or null if no subscription CLI is available
917
+ /** Build the Deep-Synthesis provider, or null if no LLM provider is available
700
918
  * (the caller then falls back to the normal single-provider path). `samples` sets
701
- * the self-consistency depth for the single-CLI case. */
919
+ * the self-consistency depth for the single-provider case. */
702
920
  export async function selectEnsemble(opts = {}) {
703
921
  const workers = await selectWorkers(opts);
704
922
  // The self-consistency policy default (DEFAULT_SAMPLES) is applied HERE, not in the
@@ -706,8 +924,10 @@ export async function selectEnsemble(opts = {}) {
706
924
  // construction stays passthrough. `--samples 1` opts back out.
707
925
  return workers.length ? new EnsembleProvider(workers, { samples: opts.samples ?? DEFAULT_SAMPLES }) : null;
708
926
  }
709
- /** Pick a CLI provider to run the Critic pass (subscription-only, like the workers).
710
- * Returns null when no assistant CLI is installed verification then no-ops and the
927
+ /** Pick a provider to run the Critic pass the same resolved provider normal
928
+ * synthesis would use (subscription CLI or the opt-in openai-compat endpoint),
929
+ * honoring the same env/local-preference/auto policy. Returns null when that
930
+ * resolves to the deterministic fallback — verification then no-ops and the
711
931
  * un-audited draft stands (graceful degradation; dec_18a81c8291). */
712
932
  export async function selectVerifier(opts = {}) {
713
933
  const { provider } = await resolveSynthesisProvider(opts);
@@ -5,12 +5,23 @@ import { decisionId, bugId, constraintId } from "../core/ids.js";
5
5
  import { commitCoveredBy } from "../core/dupdetect.js";
6
6
  import { pathMatchesGlob } from "../core/glob.js";
7
7
  import { draftTripwires, knownRepoDeps } from "./tripwires.js";
8
- const CODE_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
9
- const SKIP_SUBJECT = /^(merge|revert|bump|chore\(deps\)|format|lint|wip)\b/i;
8
+ import { languageFor } from "../extractors/languages.js";
9
+ // "chore(deps):" is anchored separately (not via \b) because \b requires a
10
+ // word/non-word transition, and the character after the closing ")" is ":" or a
11
+ // space — both non-word — so no boundary ever fires there.
12
+ const SKIP_SUBJECT = /^(merge|revert|bump|format|lint|wip)\b|^chore\(deps\):/i;
10
13
  // Below this many changed code lines, a commit with no structural change and no
11
14
  // explanatory body isn't worth a paid LLM call. Tunable via HUNCH_SIG_MIN_LINES.
12
15
  const SIG_MIN_LINES = Number(process.env.HUNCH_SIG_MIN_LINES) || 12;
13
16
  const SIG_MIN_BODY = 40;
17
+ /** Trivial-subject commits (merge/revert/bump/format/...) are noise UNLESS the body
18
+ * carries real content — a squash/PR description often lands there, not on the
19
+ * subject. Gated on body length ALONE (not the full isSignificant() heuristic): a
20
+ * large auto-generated reformat or dependency-bump diff with no narrative must stay
21
+ * skipped even though it would trip isSignificant()'s line/file/structural checks. */
22
+ export function isTrivialSubject(meta) {
23
+ return SKIP_SUBJECT.test(meta.subject) && meta.body.trim().length < SIG_MIN_BODY;
24
+ }
14
25
  /** Is a commit substantive enough to spend a paid LLM synthesis call on? Pure and
15
26
  * deterministic. Any structural change (symbol/dependency delta), non-trivial
16
27
  * churn, several files, OR an explanatory commit body signals a real decision
@@ -37,9 +48,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
37
48
  const meta = commitMeta(target, root);
38
49
  if (!meta)
39
50
  return { status: "skipped", reason: "commit not found" };
40
- if (SKIP_SUBJECT.test(meta.subject))
51
+ if (isTrivialSubject(meta))
41
52
  return { status: "skipped", reason: `trivial subject: ${meta.subject}` };
42
- const codeFiles = meta.files.filter((f) => CODE_RE.test(f));
53
+ const codeFiles = meta.files.filter((f) => languageFor(f) !== null);
43
54
  if (codeFiles.length === 0)
44
55
  return { status: "skipped", reason: "no code files changed" };
45
56
  // Seed the id from the COMMIT (stable across runs), not the LLM-generated title
@@ -84,13 +95,14 @@ export async function syncCommit(store, root, sha, opts = {}) {
84
95
  // Significance gate: reserve the paid LLM for substantive commits; trivial ones
85
96
  // get the FREE deterministic draft (honestly labeled "inferred"/low-confidence,
86
97
  // so the Hunch stays accurate-by-provenance). --force always uses the provider.
87
- // Deep Synthesis (--deep): ensemble every available subscription CLI and reconcile
98
+ // Deep Synthesis (--deep): ensemble every available guarded LLM provider and reconcile
88
99
  // their drafts (agreement-weighted, confidence capped below the strict gate). Falls
89
100
  // back to the normal single-provider path when no CLI is available. Opt-in only.
90
101
  // --verify forces the LLM provider (auditing a deterministic draft is pointless) and,
91
- // like --deep, runs the Critic pass below. Subscription-only throughout (con_2ce3f2a547).
102
+ // like --deep, runs the Critic pass below. Public remotes stay behind the explicit
103
+ // metered opt-in throughout (con_2ce3f2a547).
92
104
  // An explicit private capture is storage-private AND local-only by default:
93
- // never send a sensitive diff to a subscription CLI just to create a draft.
105
+ // never send a sensitive diff to any LLM provider just to create a draft.
94
106
  // Shared mode remains an explicit team policy and keeps its existing provider
95
107
  // behavior unless the caller asked for a private capture.
96
108
  const localOnly = opts.localOnly ?? !!opts.private;
package/dist/wiki/wiki.js CHANGED
@@ -20,8 +20,8 @@
20
20
  * (`store.recs`, overlay included). Nothing lands in the public
21
21
  * repo; the manifest lives inside the overlay's .hunch/.
22
22
  *
23
- * The prose "Overview" section is optional LLM output (subscription CLI via
24
- * SynthProvider.draftProse — never a pay-per-token API); everything drift-bearing
23
+ * The prose "Overview" section is optional LLM output (a guarded configured
24
+ * SynthProvider.draftProse); everything drift-bearing
25
25
  * (anchors, invariants, structure) is rendered deterministically around it, so a
26
26
  * missing/failed CLI degrades to a complete template page, and the input hash
27
27
  * covers graph inputs only — LLM nondeterminism can never fake staleness.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
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).",
@@ -58,6 +58,7 @@
58
58
  "@modelcontextprotocol/sdk": "^1.29.0",
59
59
  "commander": "^15.0.0",
60
60
  "tree-sitter": "0.21.1",
61
+ "tree-sitter-python": "^0.23.2",
61
62
  "tree-sitter-typescript": "^0.23.2",
62
63
  "zod": "^4.4.3"
63
64
  },