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