@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.
- package/dist/cli/index.js +290 -353
- package/dist/cli/invocation.d.ts +22 -13
- package/dist/cli/invocation.js +49 -29
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/delta.js +2 -2
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/constitution/sourceMutation.js +28 -18
- package/dist/core/agenthook.js +26 -6
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/commitrepair.d.ts +43 -138
- package/dist/core/commitrepair.js +76 -167
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/correction.js +9 -9
- package/dist/core/delivery.d.ts +6 -1
- package/dist/core/delivery.js +71 -5
- 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/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/paths.d.ts +17 -24
- package/dist/core/paths.js +37 -43
- package/dist/core/projectDna.js +11 -1
- package/dist/core/refrepair.d.ts +2 -1
- package/dist/core/refrepair.js +4 -4
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/repairqueue.d.ts +17 -34
- package/dist/core/repairqueue.js +48 -98
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- 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 +77 -95
- 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 +168 -184
- package/dist/extractors/k8sManifest.d.ts +23 -80
- package/dist/extractors/k8sManifest.js +319 -690
- package/dist/extractors/languages.d.ts +37 -37
- package/dist/extractors/languages.js +83 -54
- package/dist/extractors/nativeTreeSitter.d.ts +4 -4
- package/dist/extractors/nativeTreeSitter.js +17 -14
- package/dist/extractors/parse.d.ts +27 -13
- package/dist/extractors/parse.js +120 -56
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/gitignore.js +3 -5
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +13 -16
- package/dist/integrations/hooks.js +31 -30
- package/dist/integrations/mergeDriver.js +18 -17
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.d.ts +4 -5
- package/dist/mcp/server.js +107 -81
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +20 -23
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.d.ts +19 -21
- package/dist/synthesis/provider.js +93 -154
- package/dist/synthesis/synthesize.d.ts +8 -8
- package/dist/synthesis/synthesize.js +33 -50
- package/package.json +3 -3
- 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
|
-
*
|
|
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."
|
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
130
|
-
* 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. */
|
|
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
|
|
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
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
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(
|
|
546
|
+
signal: AbortSignal.timeout(5000),
|
|
600
547
|
});
|
|
601
548
|
if (!res.ok)
|
|
602
549
|
return null;
|
|
603
|
-
const
|
|
604
|
-
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))
|
|
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;
|
|
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
|
-
//
|
|
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)
|
|
733
|
-
//
|
|
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:
|
|
745
|
-
*
|
|
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
|
-
/**
|
|
751
|
-
*
|
|
752
|
-
*
|
|
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
|
|
782
|
-
return isSynthPreference(
|
|
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
|
|
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
|
|
999
|
-
* synthesis would use (subscription CLI or the opt-in openai-compat endpoint),
|
|
1000
|
-
*
|
|
1001
|
-
* 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
|
|
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
|
-
/**
|
|
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
|
-
const SIG_MIN_LINES = Number(process.env.HUNCH_SIG_MIN_LINES)
|
|
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,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
|
-
"
|
|
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.
|
|
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
|
{
|