@davesheffer/hunch 1.45.2 → 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 (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. package/server.json +2 -2
@@ -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."
@@ -23,11 +23,11 @@
23
23
  * Every provider returns the same shape so the rest of the system never knows
24
24
  * (or cares) which one ran.
25
25
  */
26
- import { execFile, spawn } from "node:child_process";
27
26
  import { existsSync, mkdirSync, readFileSync } from "node:fs";
28
27
  import { isIP } from "node:net";
29
28
  import { tmpdir } from "node:os";
30
29
  import { dirname, join } from "node:path";
30
+ import { boundedSpawn } from "../core/boundedSpawn.js";
31
31
  import { writeFileAtomic } from "../core/io.js";
32
32
  import { summarizeDiff } from "../extractors/diff.js";
33
33
  import { languageFor } from "../extractors/languages.js";
@@ -45,89 +45,38 @@ const IS_WIN = process.platform === "win32";
45
45
  * win32 routes through cmd.exe so the shim resolves. Safe here because
46
46
  * every argv we pass is a trusted, space-free flag (the prompt is stdin).
47
47
  */
48
- export function pexecIn(cmd, args, opts = {}) {
49
- return new Promise((resolve, reject) => {
50
- // Windows: launch through cmd.exe so the `claude` .cmd/.ps1 shim resolves,
51
- // and pass the whole line as ONE shell string (args are trusted, space-free
52
- // flags) — avoids Node's DEP0190 warning for `args + shell:true`. The prompt
53
- // is never here; it goes via stdin below. POSIX: no shell, argv as-is.
54
- const child = IS_WIN
55
- ? spawn([cmd, ...args].join(" "), {
56
- shell: true,
57
- env: initiatorChildEnv(opts.env ?? process.env),
58
- cwd: opts.cwd,
59
- windowsHide: true,
60
- })
61
- : spawn(cmd, args, {
62
- env: initiatorChildEnv(opts.env ?? process.env),
63
- cwd: opts.cwd,
64
- windowsHide: true,
65
- });
66
- const max = opts.maxBuffer ?? 16 * 1024 * 1024;
67
- let out = "";
68
- let err = "";
69
- let outLen = 0;
70
- let settled = false;
71
- // Windows spawns through a cmd.exe wrapper (shell:true), and child.kill()
72
- // terminates only that wrapper — the actual agent CLI survives as an orphan,
73
- // still burning the user's subscription after every timeout, accumulating
74
- // with each post-commit hook fire (issue #44). taskkill /T fells the tree.
75
- // Best-effort by design: the wrapper kill below still runs either way.
76
- const killTree = () => {
77
- if (IS_WIN && child.pid) {
78
- try {
79
- execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }, () => { });
80
- }
81
- catch { /* taskkill unavailable — fall through to the wrapper kill */ }
82
- }
83
- child.kill();
84
- };
85
- const done = (fn) => {
86
- if (settled)
87
- return;
88
- settled = true;
89
- if (timer)
90
- clearTimeout(timer);
91
- fn();
92
- };
93
- const timer = opts.timeout
94
- ? setTimeout(() => {
95
- killTree();
96
- done(() => reject(new Error(`"${cmd}" timed out after ${opts.timeout}ms`)));
97
- }, opts.timeout)
98
- : null;
99
- child.on("error", (e) => done(() => reject(e)));
100
- child.stdout.on("data", (d) => {
101
- outLen += d.length;
102
- if (outLen > max) {
103
- killTree();
104
- done(() => reject(new Error(`"${cmd}" exceeded maxBuffer (${max} bytes)`)));
105
- return;
106
- }
107
- out += d.toString();
108
- });
109
- child.stderr.on("data", (d) => {
110
- err += d.toString();
111
- });
112
- child.on("close", (code) => {
113
- done(() => {
114
- if (code === 0)
115
- resolve({ stdout: out });
116
- else
117
- reject(new Error(`"${cmd}" exited ${code}: ${err.slice(0, 300)}`));
118
- });
119
- });
120
- // Feed stdin (the prompt) then close it; commands with no input just get EOF.
121
- if (opts.input != null)
122
- child.stdin.write(opts.input);
123
- child.stdin.on("error", () => { }); // ignore EPIPE if the child exits early
124
- child.stdin.end();
48
+ export async function pexecIn(cmd, args, opts = {}) {
49
+ // Windows: launch through cmd.exe so the `claude` .cmd/.ps1 shim resolves,
50
+ // and pass the whole line as ONE shell string (args are trusted, space-free
51
+ // flags) — avoids Node's DEP0190 warning for `args + shell:true`. The prompt
52
+ // is never here; it goes via stdin. POSIX: no shell, argv as-is.
53
+ // boundedSpawn owns the timer and, on timeout or overflow, kills the WHOLE
54
+ // tree while the root is alive: on Windows child.kill() alone ends only the
55
+ // cmd.exe wrapper and orphans the agent CLI, still burning the user's
56
+ // subscription after every timeout (issues #44, #489).
57
+ const result = await boundedSpawn(IS_WIN ? [cmd, ...args].join(" ") : cmd, IS_WIN ? [] : args, {
58
+ shell: IS_WIN,
59
+ env: initiatorChildEnv(opts.env ?? process.env),
60
+ cwd: opts.cwd,
61
+ input: opts.input,
62
+ timeoutMs: opts.timeout,
63
+ maxBuffer: opts.maxBuffer ?? 16 * 1024 * 1024,
125
64
  });
65
+ if (result.error)
66
+ throw result.error;
67
+ if (result.timedOut)
68
+ throw new Error(`"${cmd}" timed out after ${opts.timeout}ms`);
69
+ if (result.overflowed)
70
+ throw new Error(`"${cmd}" exceeded maxBuffer (${opts.maxBuffer ?? 16 * 1024 * 1024} bytes)`);
71
+ if (result.status !== 0)
72
+ throw new Error(`"${cmd}" exited ${result.status}: ${result.stderr.slice(0, 300)}`);
73
+ return { stdout: result.stdout };
126
74
  }
127
75
  /** Every selectable synthesis mode. `auto` is a preference value rather than a
128
76
  * provider: it resolves the invocation's origin without choosing by availability.
129
- * "openai-compat" is the opt-in local/self-hosted HTTP provider (Ollama, vLLM,
130
- * 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. */
131
80
  export const SYNTH_PROVIDER_NAMES = ["claude-cli", "codex-cli", "cursor-agent", "kimi-cli", "openai-compat", "deterministic"];
132
81
  export const SYNTH_PREFERENCES = ["auto", ...SYNTH_PROVIDER_NAMES];
133
82
  const PROVIDER_INFO = {
@@ -135,7 +84,7 @@ const PROVIDER_INFO = {
135
84
  "codex-cli": { label: "Codex", subscription: "ChatGPT subscription" },
136
85
  "cursor-agent": { label: "Cursor Agent", subscription: "Cursor subscription" },
137
86
  "kimi-cli": { label: "Kimi CLI", subscription: null },
138
- "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 },
139
88
  deterministic: { label: "Deterministic local fallback", subscription: null },
140
89
  };
141
90
  const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
@@ -302,23 +251,22 @@ export function safeModel(v, fallback) {
302
251
  export function codexExecArgs(model) {
303
252
  return ["exec", "--json", "--skip-git-repo-check", ...(model ? ["-m", model] : []), "-"];
304
253
  }
305
- // A timeout comes from a HUNCH_*_TIMEOUT_MS env var and feeds AbortController's
306
- // delay directly (never a shell argv token, unlike safeModel's model id) — but a
307
- // non-numeric or nonsensical value (negative, zero, NaN, Infinity) would either
308
- // abort immediately or never abort at all, so validate the same way: fall back to
309
- // the provider's default rather than propagate garbage.
310
- 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;
311
260
  const n = Number(v);
312
- 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);
313
266
  }
314
- // max_tokens caps OUTPUT length (never a shell argv token, unlike safeModel's
315
- // model id) — same failure modes as a timeout, so validate the same way: fall
316
- // back to a safe default rather than propagate garbage into the request body
317
- // (issue #11; orthogonal to the context-window/truncation problem that issue
318
- // 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`. */
319
268
  export function safeMaxTokens(v, fallback) {
320
- const n = Number(v);
321
- return v && Number.isFinite(n) && n > 0 ? n : fallback;
269
+ return positiveNumber(v, fallback);
322
270
  }
323
271
  // --------------------------------------------------------------------------
324
272
  // Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
@@ -444,21 +392,15 @@ class CursorCliProvider extends PromptSynthProvider {
444
392
  }
445
393
  }
446
394
  // --------------------------------------------------------------------------
447
- // Provider D: OpenAI-compatible / local model endpoint (Ollama, vLLM, LM
448
- // Studio, llama.cpp server, ...) — opt-in, NOT a subscription CLI. Speaks the
449
- // OpenAI chat-completions wire format over HTTP, so ONE implementation covers
450
- // any self-hosted server that implements it (Ollama's /v1 compatibility layer
451
- // included — no separate native /api/chat client). Off by default: available()
452
- // requires BOTH HUNCH_SYNTH_BASE_URL and HUNCH_SYNTH_MODEL, so an installation
453
- // with neither set behaves exactly as it did before this provider existed.
454
- //
455
- // Exported (unlike the CLI providers) so tests can construct fresh instances and
456
- // read process.env at CALL time — see run()/available() below, which read env
457
- // vars directly rather than caching them in constructor fields. That mirrors
458
- // selectProvider()'s own style (it re-reads HUNCH_SYNTH_PROVIDER on every call)
459
- // and avoids a stale-field trap: a module-level PROVIDERS singleton constructed
460
- // once at import time would otherwise never see env vars a test (or a long-lived
461
- // 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.
462
404
  // --------------------------------------------------------------------------
463
405
  // con_2ce3f2a547's boundary is "never silently bill." A denylist cannot enforce
464
406
  // that boundary: new OpenAI-compatible paid providers appear continually, and a
@@ -521,6 +463,7 @@ function isLocalOrPrivateHost(hostname) {
521
463
  function requiresMeteredOptIn(url) {
522
464
  return !isLocalOrPrivateHost(normalizedHostname(url));
523
465
  }
466
+ /** True only when the user explicitly opted in to public (possibly metered) hosts. */
524
467
  export function meteredHostsAllowed(env = process.env) {
525
468
  return env.HUNCH_SYNTH_ALLOW_METERED === "1";
526
469
  }
@@ -536,8 +479,9 @@ export class OpenAICompatProvider extends PromptSynthProvider {
536
479
  async run(prompt, output = "json") {
537
480
  const baseUrl = process.env.HUNCH_SYNTH_BASE_URL;
538
481
  const model = safeModel(process.env.HUNCH_SYNTH_MODEL, undefined);
539
- if (!baseUrl || !model)
540
- 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
+ }
541
485
  const endpoint = parseOpenAICompatBaseUrl(baseUrl);
542
486
  if (!endpoint) {
543
487
  throw new Error("openai-compat: HUNCH_SYNTH_BASE_URL must be an http(s) base URL without credentials, a query, or a fragment");
@@ -547,7 +491,7 @@ export class OpenAICompatProvider extends PromptSynthProvider {
547
491
  }
548
492
  endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, "")}/chat/completions`;
549
493
  const apiKey = process.env.HUNCH_SYNTH_API_KEY;
550
- const timeoutMs = safeTimeout(process.env.HUNCH_SYNTH_TIMEOUT_MS, 300_000);
494
+ const timeoutMs = safeTimeout(process.env.HUNCH_SYNTH_TIMEOUT_MS, 300000);
551
495
  const controller = new AbortController();
552
496
  const timer = setTimeout(() => controller.abort(), timeoutMs);
553
497
  try {
@@ -567,12 +511,14 @@ export class OpenAICompatProvider extends PromptSynthProvider {
567
511
  signal: controller.signal,
568
512
  });
569
513
  if (!res.ok) {
570
- 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");
571
521
  }
572
- const body = (await res.json());
573
- const content = body.choices?.[0]?.message?.content;
574
- if (!content)
575
- throw new Error("openai-compat endpoint returned no message content");
576
522
  return content;
577
523
  }
578
524
  finally {
@@ -582,9 +528,10 @@ export class OpenAICompatProvider extends PromptSynthProvider {
582
528
  }
583
529
  /** Best-effort: does the configured openai-compat endpoint look like Ollama with
584
530
  * an UNSET num_ctx? Returns
585
- * an advisory warning string when so, or null when the endpoint isn't reachable,
586
- * doesn't look like Ollama's /api/show shape, or already has num_ctx set — this
587
- * 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
588
535
  * report the model's effective context length: modern Ollama defaults may come
589
536
  * from server configuration or VRAM tiers, and model_info keys are not a stable
590
537
  * parse target. We therefore report only the observed fact — whether num_ctx is
@@ -596,19 +543,19 @@ export async function probeOllamaNumCtx(baseUrl, model) {
596
543
  method: "POST",
597
544
  headers: { "content-type": "application/json" },
598
545
  body: JSON.stringify({ name: model }),
599
- signal: AbortSignal.timeout(5_000),
546
+ signal: AbortSignal.timeout(5000),
600
547
  });
601
548
  if (!res.ok)
602
549
  return null;
603
- const body = (await res.json());
604
- 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))
605
554
  return null;
606
- if (/^num_ctx\s+\d+/m.test(body.parameters))
607
- return null; // already configured — nothing to warn about
608
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.";
609
556
  }
610
557
  catch {
611
- return null; // not Ollama, unreachable, or an unexpected response shape — advisory only, never throw
558
+ return null;
612
559
  }
613
560
  }
614
561
  // --------------------------------------------------------------------------
@@ -623,8 +570,7 @@ export class DeterministicProvider {
623
570
  const dirs = topDirs(input.files);
624
571
  const a = input.analysis;
625
572
  const summary = a ? summarizeDiff(a) : "";
626
- // input.files can be markdown-only (issue #12) — don't claim "code" for a
627
- // commit that touched none.
573
+ // A commit touching no code (issue #12) must not be described as "code".
628
574
  const noun = input.files.some((f) => languageFor(f) !== null) ? "code" : "content";
629
575
  const verb = /^(add|introduce|create|feat)/i.test(input.subject) ? "introduced"
630
576
  : /^(remove|delete|drop)/i.test(input.subject) ? "removed"
@@ -729,9 +675,8 @@ const PROVIDERS = [
729
675
  // Availability rarely changes within a process (a CLI doesn't get installed mid-run),
730
676
  // and selection runs on every sync/recordFailure. Cache by object identity rather than
731
677
  // name so injected test registries never inherit a stale result from another provider.
732
- // A plain Map (not WeakMap): __resetAvailabilityCacheForTests below needs .clear(),
733
- // which WeakMap doesn't support — the module's singleton PROVIDERS array is the only
734
- // 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.
735
680
  const availCache = new Map();
736
681
  function isAvailable(p) {
737
682
  let v = availCache.get(p);
@@ -741,21 +686,14 @@ function isAvailable(p) {
741
686
  }
742
687
  return v;
743
688
  }
744
- /** Test-only: clears the availability memoization cache so a test that toggles
745
- * env vars mid-process (e.g. HUNCH_SYNTH_BASE_URL) isn't served a stale result
746
- * 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. */
747
691
  export function __resetAvailabilityCacheForTests() {
748
692
  availCache.clear();
749
693
  }
750
- /** "ollama" is accepted as an alias for "openai-compat" — the provider is not
751
- * Ollama-specific (it speaks the OpenAI chat-completions format any self-hosted
752
- * server can implement), but Ollama is the most common self-hosted target and
753
- * users reach for that name first. Applied to the HUNCH_SYNTH_PROVIDER env var in
754
- * resolveSynthesisProvider below, and exported so the `hunch provider <name>` CLI
755
- * command (index.ts) normalizes it the same way before validating/persisting a
756
- * local preference — the two paths must agree, or a user who sets one and reads
757
- * the other back gets a confusing "unknown provider" message for a name that
758
- * 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. */
759
697
  export function normalizeProviderName(v) {
760
698
  return v === "ollama" ? "openai-compat" : v;
761
699
  }
@@ -778,8 +716,8 @@ export function readSynthesisPreference(root) {
778
716
  if (!existsSync(file))
779
717
  return "auto";
780
718
  const parsed = JSON.parse(readFileSync(file, "utf8"));
781
- const normalized = typeof parsed.synthProvider === "string" ? normalizeProviderName(parsed.synthProvider) : undefined;
782
- return isSynthPreference(normalized) ? normalized : "auto";
719
+ const stored = typeof parsed.synthProvider === "string" ? normalizeProviderName(parsed.synthProvider) : undefined;
720
+ return isSynthPreference(stored) ? stored : "auto";
783
721
  }
784
722
  catch {
785
723
  return "auto";
@@ -833,7 +771,8 @@ export async function resolveSynthesisProvider(opts = {}) {
833
771
  const provider = find(name);
834
772
  return provider && await isAvailable(provider) ? provider : undefined;
835
773
  };
836
- const environment = normalizeProviderName(env.HUNCH_SYNTH_PROVIDER?.trim());
774
+ const rawEnvironment = env.HUNCH_SYNTH_PROVIDER?.trim();
775
+ const environment = rawEnvironment ? normalizeProviderName(rawEnvironment) : undefined;
837
776
  // Explicit offline/privacy mode always wins: origin binding must never turn it into a model call.
838
777
  if (environment === "deterministic")
839
778
  return { provider: fallback, source: "environment", preference: "deterministic", statuses };
@@ -995,10 +934,10 @@ export async function selectEnsemble(opts = {}) {
995
934
  // construction stays passthrough. `--samples 1` opts back out.
996
935
  return workers.length ? new EnsembleProvider(workers, { samples: opts.samples ?? DEFAULT_SAMPLES }) : null;
997
936
  }
998
- /** Pick a provider to run the Critic pass — the same resolved provider normal
999
- * synthesis would use (subscription CLI or the opt-in openai-compat endpoint),
1000
- * honoring the same env/local-preference/auto policy. Returns null when that
1001
- * 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
1002
941
  * un-audited draft stands (graceful degradation; dec_18a81c8291). */
1003
942
  export async function selectVerifier(opts = {}) {
1004
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
- const SIG_MIN_LINES = Number(process.env.HUNCH_SIG_MIN_LINES) || 12;
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.45.2",
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,7 +102,8 @@
103
102
  "tree-sitter-php": "0.23.12",
104
103
  "tree-sitter-python": "0.23.4",
105
104
  "tree-sitter-typescript": "^0.23.2",
106
- "zod": "^4.4.3"
105
+ "tree-sitter-yaml": "npm:@tree-sitter-grammars/tree-sitter-yaml@^0.6.1",
106
+ "zod": "^4.6.6"
107
107
  },
108
108
  "devDependencies": {
109
109
  "@types/cross-spawn": "^6.0.6",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.45.2",
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.45.2",
16
+ "version": "1.47.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {