@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.
- package/dist/cli/index.js +151 -321
- package/dist/cli/invocation.d.ts +8 -13
- package/dist/cli/invocation.js +26 -29
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/correction.js +9 -9
- package/dist/core/docanchors.js +9 -9
- package/dist/core/drift.js +22 -27
- package/dist/core/escalations.d.ts +16 -58
- package/dist/core/escalations.js +39 -121
- package/dist/core/glob.d.ts +7 -7
- package/dist/core/glob.js +12 -11
- package/dist/core/groundingLag.d.ts +2 -9
- package/dist/core/groundingLag.js +5 -11
- package/dist/core/groundingMerge.d.ts +2 -7
- package/dist/core/groundingMerge.js +35 -54
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/types.d.ts +3 -3
- package/dist/core/types.js +1 -1
- package/dist/extractors/diff.d.ts +4 -4
- package/dist/extractors/diff.js +25 -29
- package/dist/extractors/git.d.ts +8 -23
- package/dist/extractors/git.js +45 -64
- package/dist/extractors/helm.d.ts +10 -0
- package/dist/extractors/helm.js +43 -59
- package/dist/extractors/indexer.d.ts +6 -0
- package/dist/extractors/indexer.js +126 -178
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +28 -37
- package/dist/extractors/languages.js +55 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +7 -13
- package/dist/extractors/parse.js +39 -51
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/hooks.d.ts +9 -14
- package/dist/integrations/hooks.js +19 -28
- package/dist/integrations/mergeDriver.js +13 -14
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +60 -73
- package/dist/store/hunchStore.js +12 -18
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +67 -76
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +32 -49
- package/package.json +2 -2
- 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
|
|
115
|
-
* LM Studio
|
|
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
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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:
|
|
207
|
-
*
|
|
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
|
-
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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
|
|
262
|
-
* synthesis would use (subscription CLI or the opt-in openai-compat endpoint),
|
|
263
|
-
*
|
|
264
|
-
* resolves to the deterministic fallback
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
|
78
|
-
* LM Studio
|
|
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
|
|
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
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
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(
|
|
546
|
+
signal: AbortSignal.timeout(5000),
|
|
548
547
|
});
|
|
549
548
|
if (!res.ok)
|
|
550
549
|
return null;
|
|
551
|
-
const
|
|
552
|
-
if (typeof
|
|
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;
|
|
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
|
-
//
|
|
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)
|
|
681
|
-
//
|
|
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:
|
|
693
|
-
*
|
|
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
|
-
/**
|
|
699
|
-
*
|
|
700
|
-
*
|
|
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
|
|
730
|
-
return isSynthPreference(
|
|
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
|
|
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
|
|
947
|
-
* synthesis would use (subscription CLI or the opt-in openai-compat endpoint),
|
|
948
|
-
*
|
|
949
|
-
* resolves to the deterministic fallback
|
|
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
|
-
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
35
|
-
* `files` is
|
|
36
|
-
*
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
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
|
-
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
30
|
-
* `files` is
|
|
31
|
-
*
|
|
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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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/**)
|
|
60
|
+
return { status: "skipped", reason: "commit touches Hunch's own store (.hunch/**), which is circular and never synthesis input" };
|
|
69
61
|
}
|
|
70
|
-
|
|
71
|
-
|
|
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 ||
|
|
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
|
-
//
|
|
146
|
-
//
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
543
|
-
//
|
|
544
|
-
|
|
545
|
-
|
|
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 (
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
16
|
+
"version": "1.47.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|