@davesheffer/hunch 1.46.0 → 1.47.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.
Files changed (56) hide show
  1. package/dist/cli/index.js +151 -321
  2. package/dist/cli/invocation.d.ts +8 -13
  3. package/dist/cli/invocation.js +26 -29
  4. package/dist/constitution/delta.js +2 -2
  5. package/dist/constitution/sourceMutation.js +28 -18
  6. package/dist/core/commitrepair.d.ts +43 -138
  7. package/dist/core/commitrepair.js +76 -167
  8. package/dist/core/correction.js +9 -9
  9. package/dist/core/docanchors.js +9 -9
  10. package/dist/core/drift.js +22 -27
  11. package/dist/core/escalations.d.ts +16 -58
  12. package/dist/core/escalations.js +39 -121
  13. package/dist/core/glob.d.ts +7 -7
  14. package/dist/core/glob.js +12 -11
  15. package/dist/core/groundingLag.d.ts +2 -9
  16. package/dist/core/groundingLag.js +5 -11
  17. package/dist/core/groundingMerge.d.ts +2 -7
  18. package/dist/core/groundingMerge.js +35 -54
  19. package/dist/core/paths.d.ts +17 -24
  20. package/dist/core/paths.js +37 -43
  21. package/dist/core/refrepair.d.ts +2 -1
  22. package/dist/core/refrepair.js +4 -4
  23. package/dist/core/repairqueue.d.ts +17 -34
  24. package/dist/core/repairqueue.js +48 -98
  25. package/dist/core/types.d.ts +3 -3
  26. package/dist/core/types.js +1 -1
  27. package/dist/extractors/diff.d.ts +4 -4
  28. package/dist/extractors/diff.js +25 -29
  29. package/dist/extractors/git.d.ts +8 -23
  30. package/dist/extractors/git.js +45 -64
  31. package/dist/extractors/helm.d.ts +10 -0
  32. package/dist/extractors/helm.js +43 -59
  33. package/dist/extractors/indexer.d.ts +6 -0
  34. package/dist/extractors/indexer.js +126 -178
  35. package/dist/extractors/k8sManifest.d.ts +23 -80
  36. package/dist/extractors/k8sManifest.js +319 -690
  37. package/dist/extractors/languages.d.ts +28 -37
  38. package/dist/extractors/languages.js +55 -54
  39. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  40. package/dist/extractors/nativeTreeSitter.js +17 -14
  41. package/dist/extractors/parse.d.ts +7 -13
  42. package/dist/extractors/parse.js +39 -51
  43. package/dist/integrations/claudemd.js +1 -0
  44. package/dist/integrations/gitignore.js +3 -5
  45. package/dist/integrations/hooks.d.ts +9 -14
  46. package/dist/integrations/hooks.js +19 -28
  47. package/dist/integrations/mergeDriver.js +13 -14
  48. package/dist/mcp/server.d.ts +4 -5
  49. package/dist/mcp/server.js +60 -73
  50. package/dist/store/hunchStore.js +12 -18
  51. package/dist/synthesis/provider.d.ts +19 -21
  52. package/dist/synthesis/provider.js +67 -76
  53. package/dist/synthesis/synthesize.d.ts +8 -8
  54. package/dist/synthesis/synthesize.js +32 -49
  55. package/package.json +2 -2
  56. package/server.json +2 -2
@@ -111,8 +111,9 @@ export interface SynthProvider {
111
111
  }
112
112
  /** Every selectable synthesis mode. `auto` is a preference value rather than a
113
113
  * provider: it resolves the invocation's origin without choosing by availability.
114
- * "openai-compat" is the opt-in local/self-hosted HTTP provider (Ollama, vLLM,
115
- * LM Studio, ...) — not a subscription, but explicitly selectable like one. */
114
+ * "openai-compat" is the opt-in local or self-hosted HTTP provider (Ollama, vLLM,
115
+ * LM Studio and so on). It is not a subscription, but it can be selected
116
+ * explicitly like one. */
116
117
  export declare const SYNTH_PROVIDER_NAMES: readonly ["claude-cli", "codex-cli", "cursor-agent", "kimi-cli", "openai-compat", "deterministic"];
117
118
  export declare const SYNTH_PREFERENCES: readonly ["auto", "claude-cli", "codex-cli", "cursor-agent", "kimi-cli", "openai-compat", "deterministic"];
118
119
  export type SynthProviderName = (typeof SYNTH_PROVIDER_NAMES)[number];
@@ -172,8 +173,11 @@ export declare function safeModel(v: string | undefined, fallback: undefined): s
172
173
  * the explicit skip flag preserves that neutral-cwd isolation without loading a
173
174
  * target repo's agent rules or MCP configuration. */
174
175
  export declare function codexExecArgs(model?: string): string[];
176
+ /** Sanitize HUNCH_SYNTH_TIMEOUT_MS: a finite number > 0, else `fallback`. */
175
177
  export declare function safeTimeout(v: string | undefined, fallback: number): number;
178
+ /** Sanitize HUNCH_SYNTH_MAX_TOKENS: a finite number > 0, else `fallback`. */
176
179
  export declare function safeMaxTokens(v: string | undefined, fallback: number): number;
180
+ /** True only when the user explicitly opted in to public (possibly metered) hosts. */
177
181
  export declare function meteredHostsAllowed(env?: NodeJS.ProcessEnv): boolean;
178
182
  export declare class OpenAICompatProvider extends PromptSynthProvider {
179
183
  readonly name = "openai-compat";
@@ -182,9 +186,10 @@ export declare class OpenAICompatProvider extends PromptSynthProvider {
182
186
  }
183
187
  /** Best-effort: does the configured openai-compat endpoint look like Ollama with
184
188
  * an UNSET num_ctx? Returns
185
- * an advisory warning string when so, or null when the endpoint isn't reachable,
186
- * doesn't look like Ollama's /api/show shape, or already has num_ctx set — this
187
- * is diagnostics only, never thrown, never blocking. Deliberately does NOT try to
189
+ * a warning string when the model does not pin num_ctx, or null when the
190
+ * endpoint is unreachable, does not look like Ollama's /api/show response shape,
191
+ * or already has num_ctx set. Diagnostics only: it never throws and never blocks.
192
+ * Deliberately does NOT try to
188
193
  * report the model's effective context length: modern Ollama defaults may come
189
194
  * from server configuration or VRAM tiers, and model_info keys are not a stable
190
195
  * parse target. We therefore report only the observed fact — whether num_ctx is
@@ -203,19 +208,12 @@ export declare class DeterministicProvider implements SynthProvider {
203
208
  * tagged. If nothing parses, hand the raw output to the mapper (→ it finds the JSON
204
209
  * draft or throws → deterministic fallback). Tolerant by design: drift degrades, never crashes. */
205
210
  export declare function extractCodexText(out: string): string;
206
- /** Test-only: clears the availability memoization cache so a test that toggles
207
- * env vars mid-process (e.g. HUNCH_SYNTH_BASE_URL) isn't served a stale result
208
- * cached by an earlier call in the same process. Never call from production code. */
211
+ /** Test-only: empty the availability cache so a test that changes env vars
212
+ * mid-process is not served a stale cached result. */
209
213
  export declare function __resetAvailabilityCacheForTests(): void;
210
- /** "ollama" is accepted as an alias for "openai-compat" — the provider is not
211
- * Ollama-specific (it speaks the OpenAI chat-completions format any self-hosted
212
- * server can implement), but Ollama is the most common self-hosted target and
213
- * users reach for that name first. Applied to the HUNCH_SYNTH_PROVIDER env var in
214
- * resolveSynthesisProvider below, and exported so the `hunch provider <name>` CLI
215
- * command (index.ts) normalizes it the same way before validating/persisting a
216
- * local preference — the two paths must agree, or a user who sets one and reads
217
- * the other back gets a confusing "unknown provider" message for a name that
218
- * actually works. */
214
+ /** The provider is not Ollama-specific, but Ollama is the name users try first, so
215
+ * "ollama" is an alias for "openai-compat". The env var and `hunch provider <name>`
216
+ * both normalize through here so the two paths agree. */
219
217
  export declare function normalizeProviderName(v: string | undefined): string | undefined;
220
218
  /** Read a per-user, gitignored choice. Invalid/missing local state is treated as auto;
221
219
  * `writeSynthesisPreference` refuses to overwrite malformed data so this forgiveness
@@ -258,10 +256,10 @@ export declare class EnsembleProvider implements SynthProvider {
258
256
  export declare function selectEnsemble(opts?: ProviderSelectionOptions & {
259
257
  samples?: number;
260
258
  }): Promise<EnsembleProvider | null>;
261
- /** Pick a provider to run the Critic pass — the same resolved provider normal
262
- * synthesis would use (subscription CLI or the opt-in openai-compat endpoint),
263
- * honoring the same env/local-preference/auto policy. Returns null when that
264
- * resolves to the deterministic fallback — verification then no-ops and the
259
+ /** Pick the provider for the Critic pass: the same resolved provider normal
260
+ * synthesis would use (a subscription CLI or the opt-in openai-compat endpoint),
261
+ * under the same env / local-preference / auto policy. Returns null when that
262
+ * resolves to the deterministic fallback, in which case verification does nothing. The
265
263
  * un-audited draft stands (graceful degradation; dec_18a81c8291). */
266
264
  export declare function selectVerifier(opts?: ProviderSelectionOptions): Promise<SynthProvider | null>;
267
265
  /** Map model text → RelevanceVerdict, or null when nothing usable parses (→ the
@@ -13,9 +13,9 @@
13
13
  * child env wherever the CLI would otherwise prefer them. There is intentionally
14
14
  * NO direct API-key provider.
15
15
  *
16
- * A fourth, OPT-IN provider (name "openai-compat", alias "ollama") speaks the
17
- * OpenAI chat-completions format over HTTP to a self-hosted endpoint instead of a
18
- * subscription CLI. It stays off unless HUNCH_SYNTH_BASE_URL and HUNCH_SYNTH_MODEL
16
+ * Provider D (opt-in): "openai-compat" (alias "ollama") speaks the OpenAI
17
+ * chat-completions format over HTTP to a self-hosted endpoint instead of running
18
+ * a subscription CLI. It stays off unless HUNCH_SYNTH_BASE_URL and HUNCH_SYNTH_MODEL
19
19
  * are both explicitly set. Local/LAN endpoints work directly; every public remote
20
20
  * requires HUNCH_SYNTH_ALLOW_METERED=1 because billing cannot be inferred safely
21
21
  * from a hostname — con_2ce3f2a547's spirit is "never silently bill."
@@ -74,8 +74,9 @@ export async function pexecIn(cmd, args, opts = {}) {
74
74
  }
75
75
  /** Every selectable synthesis mode. `auto` is a preference value rather than a
76
76
  * provider: it resolves the invocation's origin without choosing by availability.
77
- * "openai-compat" is the opt-in local/self-hosted HTTP provider (Ollama, vLLM,
78
- * LM Studio, ...) — not a subscription, but explicitly selectable like one. */
77
+ * "openai-compat" is the opt-in local or self-hosted HTTP provider (Ollama, vLLM,
78
+ * LM Studio and so on). It is not a subscription, but it can be selected
79
+ * explicitly like one. */
79
80
  export const SYNTH_PROVIDER_NAMES = ["claude-cli", "codex-cli", "cursor-agent", "kimi-cli", "openai-compat", "deterministic"];
80
81
  export const SYNTH_PREFERENCES = ["auto", ...SYNTH_PROVIDER_NAMES];
81
82
  const PROVIDER_INFO = {
@@ -83,7 +84,7 @@ const PROVIDER_INFO = {
83
84
  "codex-cli": { label: "Codex", subscription: "ChatGPT subscription" },
84
85
  "cursor-agent": { label: "Cursor Agent", subscription: "Cursor subscription" },
85
86
  "kimi-cli": { label: "Kimi CLI", subscription: null },
86
- "openai-compat": { label: "Self-hosted / local model (Ollama, vLLM, LM Studio, ...)", subscription: null },
87
+ "openai-compat": { label: "Self-hosted / local model (Ollama, vLLM, LM Studio)", subscription: null },
87
88
  deterministic: { label: "Deterministic local fallback", subscription: null },
88
89
  };
89
90
  const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
@@ -250,23 +251,22 @@ export function safeModel(v, fallback) {
250
251
  export function codexExecArgs(model) {
251
252
  return ["exec", "--json", "--skip-git-repo-check", ...(model ? ["-m", model] : []), "-"];
252
253
  }
253
- // A timeout comes from a HUNCH_*_TIMEOUT_MS env var and feeds AbortController's
254
- // delay directly (never a shell argv token, unlike safeModel's model id) — but a
255
- // non-numeric or nonsensical value (negative, zero, NaN, Infinity) would either
256
- // abort immediately or never abort at all, so validate the same way: fall back to
257
- // the provider's default rather than propagate garbage.
258
- export function safeTimeout(v, fallback) {
254
+ // The timeout drives an abort delay and max_tokens caps output length; neither is
255
+ // a shell argv token. A garbage value would abort at once, never abort, or corrupt
256
+ // the request body, so anything but a finite number > 0 falls back to the default.
257
+ function positiveNumber(v, fallback) {
258
+ if (typeof v !== "string" || v.trim() === "")
259
+ return fallback;
259
260
  const n = Number(v);
260
- return v && Number.isFinite(n) && n > 0 ? n : fallback;
261
+ return Number.isFinite(n) && n > 0 ? n : fallback;
262
+ }
263
+ /** Sanitize HUNCH_SYNTH_TIMEOUT_MS: a finite number > 0, else `fallback`. */
264
+ export function safeTimeout(v, fallback) {
265
+ return positiveNumber(v, fallback);
261
266
  }
262
- // max_tokens caps OUTPUT length (never a shell argv token, unlike safeModel's
263
- // model id) — same failure modes as a timeout, so validate the same way: fall
264
- // back to a safe default rather than propagate garbage into the request body
265
- // (issue #11; orthogonal to the context-window/truncation problem that issue
266
- // is mainly about — this only bounds how much the model is allowed to WRITE).
267
+ /** Sanitize HUNCH_SYNTH_MAX_TOKENS: a finite number > 0, else `fallback`. */
267
268
  export function safeMaxTokens(v, fallback) {
268
- const n = Number(v);
269
- return v && Number.isFinite(n) && n > 0 ? n : fallback;
269
+ return positiveNumber(v, fallback);
270
270
  }
271
271
  // --------------------------------------------------------------------------
272
272
  // Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
@@ -392,21 +392,15 @@ class CursorCliProvider extends PromptSynthProvider {
392
392
  }
393
393
  }
394
394
  // --------------------------------------------------------------------------
395
- // Provider D: OpenAI-compatible / local model endpoint (Ollama, vLLM, LM
396
- // Studio, llama.cpp server, ...) — opt-in, NOT a subscription CLI. Speaks the
397
- // OpenAI chat-completions wire format over HTTP, so ONE implementation covers
398
- // any self-hosted server that implements it (Ollama's /v1 compatibility layer
399
- // included — no separate native /api/chat client). Off by default: available()
400
- // requires BOTH HUNCH_SYNTH_BASE_URL and HUNCH_SYNTH_MODEL, so an installation
401
- // with neither set behaves exactly as it did before this provider existed.
402
- //
403
- // Exported (unlike the CLI providers) so tests can construct fresh instances and
404
- // read process.env at CALL time — see run()/available() below, which read env
405
- // vars directly rather than caching them in constructor fields. That mirrors
406
- // selectProvider()'s own style (it re-reads HUNCH_SYNTH_PROVIDER on every call)
407
- // and avoids a stale-field trap: a module-level PROVIDERS singleton constructed
408
- // once at import time would otherwise never see env vars a test (or a long-lived
409
- // process) sets afterward.
395
+ // Provider D: OpenAI-compatible / local model endpoint (opt-in, NOT a subscription CLI)
396
+ // One implementation covers any server that implements the chat-completions
397
+ // format, including Ollama's /v1 compatibility layer; there is no separate native
398
+ // Ollama client. It is off by default: availability needs both HUNCH_SYNTH_BASE_URL
399
+ // and HUNCH_SYNTH_MODEL, so an install with neither set behaves as if the provider
400
+ // did not exist. Unlike the CLI providers the class is exported, so tests can
401
+ // build fresh instances. It reads env vars at call time instead of caching them in
402
+ // fields, avoiding a stale value in the module-level registry singleton, which is
403
+ // created once at import time.
410
404
  // --------------------------------------------------------------------------
411
405
  // con_2ce3f2a547's boundary is "never silently bill." A denylist cannot enforce
412
406
  // that boundary: new OpenAI-compatible paid providers appear continually, and a
@@ -469,6 +463,7 @@ function isLocalOrPrivateHost(hostname) {
469
463
  function requiresMeteredOptIn(url) {
470
464
  return !isLocalOrPrivateHost(normalizedHostname(url));
471
465
  }
466
+ /** True only when the user explicitly opted in to public (possibly metered) hosts. */
472
467
  export function meteredHostsAllowed(env = process.env) {
473
468
  return env.HUNCH_SYNTH_ALLOW_METERED === "1";
474
469
  }
@@ -484,8 +479,9 @@ export class OpenAICompatProvider extends PromptSynthProvider {
484
479
  async run(prompt, output = "json") {
485
480
  const baseUrl = process.env.HUNCH_SYNTH_BASE_URL;
486
481
  const model = safeModel(process.env.HUNCH_SYNTH_MODEL, undefined);
487
- if (!baseUrl || !model)
488
- throw new Error("openai-compat: HUNCH_SYNTH_BASE_URL/HUNCH_SYNTH_MODEL not set");
482
+ if (!baseUrl || !model) {
483
+ throw new Error("openai-compat: HUNCH_SYNTH_BASE_URL and HUNCH_SYNTH_MODEL are not set");
484
+ }
489
485
  const endpoint = parseOpenAICompatBaseUrl(baseUrl);
490
486
  if (!endpoint) {
491
487
  throw new Error("openai-compat: HUNCH_SYNTH_BASE_URL must be an http(s) base URL without credentials, a query, or a fragment");
@@ -495,7 +491,7 @@ export class OpenAICompatProvider extends PromptSynthProvider {
495
491
  }
496
492
  endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, "")}/chat/completions`;
497
493
  const apiKey = process.env.HUNCH_SYNTH_API_KEY;
498
- const timeoutMs = safeTimeout(process.env.HUNCH_SYNTH_TIMEOUT_MS, 300_000);
494
+ const timeoutMs = safeTimeout(process.env.HUNCH_SYNTH_TIMEOUT_MS, 300000);
499
495
  const controller = new AbortController();
500
496
  const timer = setTimeout(() => controller.abort(), timeoutMs);
501
497
  try {
@@ -515,12 +511,14 @@ export class OpenAICompatProvider extends PromptSynthProvider {
515
511
  signal: controller.signal,
516
512
  });
517
513
  if (!res.ok) {
518
- throw new Error(`openai-compat endpoint returned ${res.status}: ${(await res.text()).slice(0, 300)}`);
514
+ const text = await res.text().catch(() => "");
515
+ throw new Error(`openai-compat: ${endpoint.origin}${endpoint.pathname} returned HTTP ${res.status}: ${text.slice(0, 300)}`);
516
+ }
517
+ const json = (await res.json());
518
+ const content = json.choices?.[0]?.message?.content;
519
+ if (typeof content !== "string" || !content) {
520
+ throw new Error("openai-compat: endpoint returned no message content");
519
521
  }
520
- const body = (await res.json());
521
- const content = body.choices?.[0]?.message?.content;
522
- if (!content)
523
- throw new Error("openai-compat endpoint returned no message content");
524
522
  return content;
525
523
  }
526
524
  finally {
@@ -530,9 +528,10 @@ export class OpenAICompatProvider extends PromptSynthProvider {
530
528
  }
531
529
  /** Best-effort: does the configured openai-compat endpoint look like Ollama with
532
530
  * an UNSET num_ctx? Returns
533
- * an advisory warning string when so, or null when the endpoint isn't reachable,
534
- * doesn't look like Ollama's /api/show shape, or already has num_ctx set — this
535
- * is diagnostics only, never thrown, never blocking. Deliberately does NOT try to
531
+ * a warning string when the model does not pin num_ctx, or null when the
532
+ * endpoint is unreachable, does not look like Ollama's /api/show response shape,
533
+ * or already has num_ctx set. Diagnostics only: it never throws and never blocks.
534
+ * Deliberately does NOT try to
536
535
  * report the model's effective context length: modern Ollama defaults may come
537
536
  * from server configuration or VRAM tiers, and model_info keys are not a stable
538
537
  * parse target. We therefore report only the observed fact — whether num_ctx is
@@ -544,19 +543,19 @@ export async function probeOllamaNumCtx(baseUrl, model) {
544
543
  method: "POST",
545
544
  headers: { "content-type": "application/json" },
546
545
  body: JSON.stringify({ name: model }),
547
- signal: AbortSignal.timeout(5_000),
546
+ signal: AbortSignal.timeout(5000),
548
547
  });
549
548
  if (!res.ok)
550
549
  return null;
551
- const body = (await res.json());
552
- if (typeof body.parameters !== "string")
550
+ const json = (await res.json());
551
+ if (typeof json.parameters !== "string")
552
+ return null;
553
+ if (/^num_ctx\s+\d+/m.test(json.parameters))
553
554
  return null;
554
- if (/^num_ctx\s+\d+/m.test(body.parameters))
555
- return null; // already configured — nothing to warn about
556
555
  return "⚠ This Ollama model does not pin num_ctx; its effective context depends on server/VRAM defaults. For stable large-diff synthesis, see https://www.hunchmemory.com/cookbook and pin num_ctx via a custom Modelfile.";
557
556
  }
558
557
  catch {
559
- return null; // not Ollama, unreachable, or an unexpected response shape — advisory only, never throw
558
+ return null;
560
559
  }
561
560
  }
562
561
  // --------------------------------------------------------------------------
@@ -571,8 +570,7 @@ export class DeterministicProvider {
571
570
  const dirs = topDirs(input.files);
572
571
  const a = input.analysis;
573
572
  const summary = a ? summarizeDiff(a) : "";
574
- // input.files can be markdown-only (issue #12) — don't claim "code" for a
575
- // commit that touched none.
573
+ // A commit touching no code (issue #12) must not be described as "code".
576
574
  const noun = input.files.some((f) => languageFor(f) !== null) ? "code" : "content";
577
575
  const verb = /^(add|introduce|create|feat)/i.test(input.subject) ? "introduced"
578
576
  : /^(remove|delete|drop)/i.test(input.subject) ? "removed"
@@ -677,9 +675,8 @@ const PROVIDERS = [
677
675
  // Availability rarely changes within a process (a CLI doesn't get installed mid-run),
678
676
  // and selection runs on every sync/recordFailure. Cache by object identity rather than
679
677
  // name so injected test registries never inherit a stale result from another provider.
680
- // A plain Map (not WeakMap): __resetAvailabilityCacheForTests below needs .clear(),
681
- // which WeakMap doesn't support — the module's singleton PROVIDERS array is the only
682
- // thing that ever populates this in production, so there's no unbounded-growth risk.
678
+ // A plain Map (not a WeakMap) so the test-only reset below can clear it. Only the
679
+ // singleton registry fills it in production, so it cannot grow without bound.
683
680
  const availCache = new Map();
684
681
  function isAvailable(p) {
685
682
  let v = availCache.get(p);
@@ -689,21 +686,14 @@ function isAvailable(p) {
689
686
  }
690
687
  return v;
691
688
  }
692
- /** Test-only: clears the availability memoization cache so a test that toggles
693
- * env vars mid-process (e.g. HUNCH_SYNTH_BASE_URL) isn't served a stale result
694
- * cached by an earlier call in the same process. Never call from production code. */
689
+ /** Test-only: empty the availability cache so a test that changes env vars
690
+ * mid-process is not served a stale cached result. */
695
691
  export function __resetAvailabilityCacheForTests() {
696
692
  availCache.clear();
697
693
  }
698
- /** "ollama" is accepted as an alias for "openai-compat" — the provider is not
699
- * Ollama-specific (it speaks the OpenAI chat-completions format any self-hosted
700
- * server can implement), but Ollama is the most common self-hosted target and
701
- * users reach for that name first. Applied to the HUNCH_SYNTH_PROVIDER env var in
702
- * resolveSynthesisProvider below, and exported so the `hunch provider <name>` CLI
703
- * command (index.ts) normalizes it the same way before validating/persisting a
704
- * local preference — the two paths must agree, or a user who sets one and reads
705
- * the other back gets a confusing "unknown provider" message for a name that
706
- * actually works. */
694
+ /** The provider is not Ollama-specific, but Ollama is the name users try first, so
695
+ * "ollama" is an alias for "openai-compat". The env var and `hunch provider <name>`
696
+ * both normalize through here so the two paths agree. */
707
697
  export function normalizeProviderName(v) {
708
698
  return v === "ollama" ? "openai-compat" : v;
709
699
  }
@@ -726,8 +716,8 @@ export function readSynthesisPreference(root) {
726
716
  if (!existsSync(file))
727
717
  return "auto";
728
718
  const parsed = JSON.parse(readFileSync(file, "utf8"));
729
- const normalized = typeof parsed.synthProvider === "string" ? normalizeProviderName(parsed.synthProvider) : undefined;
730
- return isSynthPreference(normalized) ? normalized : "auto";
719
+ const stored = typeof parsed.synthProvider === "string" ? normalizeProviderName(parsed.synthProvider) : undefined;
720
+ return isSynthPreference(stored) ? stored : "auto";
731
721
  }
732
722
  catch {
733
723
  return "auto";
@@ -781,7 +771,8 @@ export async function resolveSynthesisProvider(opts = {}) {
781
771
  const provider = find(name);
782
772
  return provider && await isAvailable(provider) ? provider : undefined;
783
773
  };
784
- const environment = normalizeProviderName(env.HUNCH_SYNTH_PROVIDER?.trim());
774
+ const rawEnvironment = env.HUNCH_SYNTH_PROVIDER?.trim();
775
+ const environment = rawEnvironment ? normalizeProviderName(rawEnvironment) : undefined;
785
776
  // Explicit offline/privacy mode always wins: origin binding must never turn it into a model call.
786
777
  if (environment === "deterministic")
787
778
  return { provider: fallback, source: "environment", preference: "deterministic", statuses };
@@ -943,10 +934,10 @@ export async function selectEnsemble(opts = {}) {
943
934
  // construction stays passthrough. `--samples 1` opts back out.
944
935
  return workers.length ? new EnsembleProvider(workers, { samples: opts.samples ?? DEFAULT_SAMPLES }) : null;
945
936
  }
946
- /** Pick a provider to run the Critic pass — the same resolved provider normal
947
- * synthesis would use (subscription CLI or the opt-in openai-compat endpoint),
948
- * honoring the same env/local-preference/auto policy. Returns null when that
949
- * resolves to the deterministic fallback — verification then no-ops and the
937
+ /** Pick the provider for the Critic pass: the same resolved provider normal
938
+ * synthesis would use (a subscription CLI or the opt-in openai-compat endpoint),
939
+ * under the same env / local-preference / auto policy. Returns null when that
940
+ * resolves to the deterministic fallback, in which case verification does nothing. The
950
941
  * un-audited draft stands (graceful degradation; dec_18a81c8291). */
951
942
  export async function selectVerifier(opts = {}) {
952
943
  const { provider } = await resolveSynthesisProvider(opts);
@@ -18,11 +18,11 @@ export interface SyncResult {
18
18
  decision?: Decision;
19
19
  provider?: string;
20
20
  }
21
- /** Trivial-subject commits (merge/revert/bump/format/...) are noise UNLESS the body
22
- * carries real content — a squash/PR description often lands there, not on the
23
- * subject. Gated on body length ALONE (not the full isSignificant() heuristic): a
24
- * large auto-generated reformat or dependency-bump diff with no narrative must stay
25
- * skipped even though it would trip isSignificant()'s line/file/structural checks. */
21
+ /** Is this commit mechanical noise (merge/revert/bump/format/lint/wip, dependency
22
+ * chores) with no narrative? Gates on body length alone, not the full significance
23
+ * heuristic, so a large auto-generated reformat or dependency bump with no
24
+ * narrative stays skipped, while a squash/PR description (a substantive body)
25
+ * keeps a trivial-looking subject eligible. */
26
26
  export declare function isTrivialSubject(meta: {
27
27
  subject: string;
28
28
  body: string;
@@ -31,9 +31,9 @@ export declare function isTrivialSubject(meta: {
31
31
  * deterministic. Any structural change (symbol/dependency delta), non-trivial
32
32
  * churn, several files, OR an explanatory commit body signals a real decision
33
33
  * worth the model. Everything below (typo/tweak/one-liner with no message) falls
34
- * to the free deterministic draft — shallower but honestly low-confidence.
35
- * `files` is whatever the caller is synthesizing from (code and/or markdown, per
36
- * isSubstantive) — the line/file-count checks are format-agnostic. */
34
+ * to the free deterministic draft. The line and file-count checks don't depend on
35
+ * the file format; `files` is the explicit list being synthesized (code and/or
36
+ * markdown). */
37
37
  export declare function isSignificant(meta: {
38
38
  body: string;
39
39
  }, a: DiffAnalysis, files: string[]): boolean;
@@ -6,19 +6,20 @@ import { commitCoveredBy } from "../core/dupdetect.js";
6
6
  import { pathMatchesGlob } from "../core/glob.js";
7
7
  import { draftTripwires, knownRepoDeps } from "./tripwires.js";
8
8
  import { isSubstantive } 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;
9
+ // Subjects that signal mechanical churn. `chore(deps):` is matched as a literal
10
+ // prefix on its own: a word-boundary check after the closing parenthesis would
11
+ // never fire (both ")" and ":" are non-word characters), so it cannot share the
12
+ // `\b` alternative.
13
+ const SKIP_SUBJECT = /^(?:(?:merge|revert|bump|format|lint|wip)\b|chore\(deps\):)/i;
13
14
  // Below this many changed code lines, a commit with no structural change and no
14
15
  // explanatory body isn't worth a paid LLM call. Tunable via HUNCH_SIG_MIN_LINES.
15
16
  const SIG_MIN_LINES = ((raw) => Number.isFinite(raw) && raw > 0 ? raw : 12)(Number(process.env.HUNCH_SIG_MIN_LINES));
16
17
  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. */
18
+ /** Is this commit mechanical noise (merge/revert/bump/format/lint/wip, dependency
19
+ * chores) with no narrative? Gates on body length alone, not the full significance
20
+ * heuristic, so a large auto-generated reformat or dependency bump with no
21
+ * narrative stays skipped, while a squash/PR description (a substantive body)
22
+ * keeps a trivial-looking subject eligible. */
22
23
  export function isTrivialSubject(meta) {
23
24
  return SKIP_SUBJECT.test(meta.subject) && meta.body.trim().length < SIG_MIN_BODY;
24
25
  }
@@ -26,9 +27,9 @@ export function isTrivialSubject(meta) {
26
27
  * deterministic. Any structural change (symbol/dependency delta), non-trivial
27
28
  * churn, several files, OR an explanatory commit body signals a real decision
28
29
  * worth the model. Everything below (typo/tweak/one-liner with no message) falls
29
- * to the free deterministic draft — shallower but honestly low-confidence.
30
- * `files` is whatever the caller is synthesizing from (code and/or markdown, per
31
- * isSubstantive) — the line/file-count checks are format-agnostic. */
30
+ * to the free deterministic draft. The line and file-count checks don't depend on
31
+ * the file format; `files` is the explicit list being synthesized (code and/or
32
+ * markdown). */
32
33
  export function isSignificant(meta, a, files) {
33
34
  const structural = a.addedSymbols.length + a.removedSymbols.length + a.changedSymbols.length + a.addedDeps.length + a.removedDeps.length;
34
35
  if (structural > 0)
@@ -52,27 +53,14 @@ export async function syncCommit(store, root, sha, opts = {}) {
52
53
  return { status: "skipped", reason: "commit not found" };
53
54
  if (isTrivialSubject(meta))
54
55
  return { status: "skipped", reason: `trivial subject: ${meta.subject}` };
55
- // .hunch/** is Hunch's OWN store — commitDiff already excludes it from the diff
56
- // CONTENT via DIFF_NOISE ("circular noise": re-synthesizing a commit that wrote
57
- // it would draft a decision about Hunch's own bookkeeping). That guard never
58
- // covered this file-LIST gate, and before #12 it didn't need to: languageFor()
59
- // already returned null for .hunch/**'s JSON and for the .md grounding docs
60
- // flushCapture regenerates alongside it (AGENTS.md/CLAUDE.md/copilot-instructions.md),
61
- // so such a commit failed "no code files changed" by coincidence. Once markdown
62
- // became substantive input those grounding docs alone made the commit eligible.
63
- // Checked by PATH, not commit-message convention (flushCapture's exact subject
64
- // wording lives as separate literals in mcp/server.ts/cli/index.ts and could
65
- // drift independently of any subject regex here) — a human editing AGENTS.md on
66
- // its own, with no .hunch/** change alongside it, stays fully synthesis-eligible.
56
+ // Hunch's own store is circular input. This checks the PATH, not the commit-message
57
+ // wording, and the decision is per path: a human editing AGENTS.md alone, with no
58
+ // .hunch change, stays eligible.
67
59
  if (meta.files.some((f) => pathMatchesGlob(f, "**/.hunch/**"))) {
68
- return { status: "skipped", reason: "touches Hunch's own store (.hunch/**) — circular, never synthesis input" };
60
+ return { status: "skipped", reason: "commit touches Hunch's own store (.hunch/**), which is circular and never synthesis input" };
69
61
  }
70
- // Substantive, not just parseable: markdown carries the *why* in a docs/ADR repo
71
- // just as legitimately as a .ts diff does, even though it has no symbol graph
72
- // (issue #12). languageFor() stays the parseability question for the symbol/dep
73
- // extraction inside analyzeDiff below.
74
- const substantiveFiles = meta.files.filter((f) => isSubstantive(f));
75
- if (substantiveFiles.length === 0)
62
+ const substantiveFiles = meta.files.filter(isSubstantive);
63
+ if (!substantiveFiles.length)
76
64
  return { status: "skipped", reason: "no code or markdown files changed" };
77
65
  // Seed the id from the COMMIT (stable across runs), not the LLM-generated title
78
66
  // (which varies) — so re-syncing a commit updates rather than dupes.
@@ -132,7 +120,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
132
120
  ? new DeterministicProvider()
133
121
  : opts.deep
134
122
  ? (await selectEnsemble({ root, samples: opts.samples })) ?? await selectProvider({ root })
135
- : opts.force || opts.verify || isSignificant(meta, analysis, substantiveFiles)
123
+ : opts.force || wantVerify || isSignificant(meta, analysis, substantiveFiles)
136
124
  ? await selectProvider({ root })
137
125
  : new DeterministicProvider();
138
126
  const input = { subject: meta.subject, body: meta.body, files: substantiveFiles, diff, analysis };
@@ -142,10 +130,8 @@ export async function syncCommit(store, root, sha, opts = {}) {
142
130
  // grounding. No-ops when no assistant CLI is available; never raises trust (dec_9a2f2fe72a).
143
131
  if (wantVerify)
144
132
  draft = await verifyDecisionSafe(await selectVerifier({ root }), input, draft);
145
- // The draft's own drafter, not merely the one selected — draftDecisionSafe sets
146
- // fellBackTo when the selected provider threw and it silently substituted the
147
- // deterministic heuristic. Every telemetry/return below must report THIS, since
148
- // backfill's LLM-vs-heuristic tally reads it directly off the return value.
133
+ // Every telemetry/return value below must report the provider that ACTUALLY drafted
134
+ // the record (backfill's LLM-vs-heuristic tally reads it from the return value).
149
135
  const actualProvider = draft.fellBackTo ?? provider.name;
150
136
  // Advisory synthesis telemetry for `hunch review` — which provider ran, how many drafts
151
137
  // were reconciled, their agreement, and the verifier's grounding. Rides in `evidence`
@@ -270,8 +256,7 @@ export async function recordFailure(store, root, failure, opts = {}) {
270
256
  suspects: suspects.map((s) => `${s.name} @ ${s.file}`),
271
257
  };
272
258
  const draft = await draftBugSafe(provider, input);
273
- // The draft's own drafter, not merely the one selected — see the identical
274
- // comment in syncCommit above.
259
+ // Report the provider that actually drafted the bug (see syncCommit).
275
260
  const actualProvider = draft.fellBackTo ?? provider.name;
276
261
  // Seed the id from the test id (stable), not the LLM title — one bug per test.
277
262
  const id = bugId(failure.test);
@@ -535,26 +520,24 @@ export async function draftDecisionSafe(provider, input) {
535
520
  try {
536
521
  return await provider.draftDecision(input);
537
522
  }
538
- catch (error) {
523
+ catch (err) {
539
524
  // A provider failure (network, bad creds, CLI crash, unparseable output) must
540
525
  // never abort the learning loop — fall back to the deterministic heuristic
541
526
  // draft, which is honestly labeled ("inferred") rather than a hollow llm_draft.
542
- // fellBackTo/fallbackReason tell the caller what ACTUALLY drafted this, not
543
- // merely what was selected — see syncCommit, which must report this instead
544
- // of the originally-selected provider's name.
545
- const draft = await new DeterministicProvider().draftDecision(input);
546
- return { ...draft, fellBackTo: "deterministic", fallbackReason: error instanceof Error ? error.message : String(error) };
527
+ // Tell the caller a fallback happened (and why) so it reports the provider that
528
+ // actually drafted the record rather than the one that was selected.
529
+ const fallback = await new DeterministicProvider().draftDecision(input);
530
+ return { ...fallback, fellBackTo: "deterministic", fallbackReason: err instanceof Error ? err.message : String(err) };
547
531
  }
548
532
  }
549
533
  export async function draftBugSafe(provider, input) {
550
534
  try {
551
535
  return await provider.draftBug(input);
552
536
  }
553
- catch (error) {
554
- // See draftDecisionSafe above — the same fallback contract applies to bugs,
555
- // and recordFailure must report the actual (fallback) provider likewise.
556
- const draft = await new DeterministicProvider().draftBug(input);
557
- return { ...draft, fellBackTo: "deterministic", fallbackReason: error instanceof Error ? error.message : String(error) };
537
+ catch (err) {
538
+ // Same contract as draftDecisionSafe: surface the fallback and its cause.
539
+ const fallback = await new DeterministicProvider().draftBug(input);
540
+ return { ...fallback, fellBackTo: "deterministic", fallbackReason: err instanceof Error ? err.message : String(err) };
558
541
  }
559
542
  }
560
543
  //# sourceMappingURL=synthesize.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.46.0",
3
+ "version": "1.47.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
@@ -94,7 +94,6 @@
94
94
  },
95
95
  "dependencies": {
96
96
  "@modelcontextprotocol/sdk": "^1.32.1",
97
- "@tree-sitter-grammars/tree-sitter-yaml": "^0.6.1",
98
97
  "commander": "^15.0.0",
99
98
  "cross-spawn": "^7.0.6",
100
99
  "smol-toml": "1.9.0",
@@ -103,6 +102,7 @@
103
102
  "tree-sitter-php": "0.23.12",
104
103
  "tree-sitter-python": "0.23.4",
105
104
  "tree-sitter-typescript": "^0.23.2",
105
+ "tree-sitter-yaml": "npm:@tree-sitter-grammars/tree-sitter-yaml@^0.6.1",
106
106
  "zod": "^4.6.6"
107
107
  },
108
108
  "devDependencies": {
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.46.0",
10
+ "version": "1.47.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.46.0",
16
+ "version": "1.47.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {