@davesheffer/hunch 1.30.0 → 1.31.1

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.
@@ -4,11 +4,10 @@
4
4
  * LLM synthesis is driven by the user's chosen coding-assistant subscription
5
5
  * CLI or an explicitly configured OpenAI-compatible endpoint. Claude Code,
6
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)
10
- * or overrides per shell with HUNCH_SYNTH_PROVIDER. Ambiguous auto mode stays
11
- * deterministic and free.
7
+ * same shape. An agent-initiated operation stays with its initiating provider,
8
+ * including verification and deep sampling. CLI availability never establishes
9
+ * origin. Explicit env/local preferences remain for human terminal invocations;
10
+ * unknown/ambiguous agent origins stay deterministic and free.
12
11
  *
13
12
  * Subscription, not API: provider-specific API credentials are removed from the
14
13
  * child env wherever the CLI would otherwise prefer them. There is intentionally
@@ -32,6 +31,8 @@ import { dirname, join } from "node:path";
32
31
  import { writeFileAtomic } from "../core/io.js";
33
32
  import { summarizeDiff } from "../extractors/diff.js";
34
33
  import { languageFor } from "../extractors/languages.js";
34
+ import { assertInitiatorProvider, currentInitiator, initiatorChildEnv } from "./initiator.js";
35
+ import { discoverAgentClis, readAgentCliConfig } from "./cliAdapter.js";
35
36
  const IS_WIN = process.platform === "win32";
36
37
  /**
37
38
  * Run a command, optionally feeding `input` to its stdin, and resolve its
@@ -53,12 +54,12 @@ export function pexecIn(cmd, args, opts = {}) {
53
54
  const child = IS_WIN
54
55
  ? spawn([cmd, ...args].join(" "), {
55
56
  shell: true,
56
- env: opts.env,
57
+ env: initiatorChildEnv(opts.env ?? process.env),
57
58
  cwd: opts.cwd,
58
59
  windowsHide: true,
59
60
  })
60
61
  : spawn(cmd, args, {
61
- env: opts.env,
62
+ env: initiatorChildEnv(opts.env ?? process.env),
62
63
  cwd: opts.cwd,
63
64
  windowsHide: true,
64
65
  });
@@ -124,15 +125,16 @@ export function pexecIn(cmd, args, opts = {}) {
124
125
  });
125
126
  }
126
127
  /** Every selectable synthesis mode. `auto` is a preference value rather than a
127
- * provider: it uses a subscription only when exactly one usable CLI is found.
128
+ * provider: it resolves the invocation's origin without choosing by availability.
128
129
  * "openai-compat" is the opt-in local/self-hosted HTTP provider (Ollama, vLLM,
129
130
  * LM Studio, ...) — not a subscription, but explicitly selectable like one. */
130
- export const SYNTH_PROVIDER_NAMES = ["claude-cli", "codex-cli", "cursor-agent", "openai-compat", "deterministic"];
131
+ export const SYNTH_PROVIDER_NAMES = ["claude-cli", "codex-cli", "cursor-agent", "kimi-cli", "openai-compat", "deterministic"];
131
132
  export const SYNTH_PREFERENCES = ["auto", ...SYNTH_PROVIDER_NAMES];
132
133
  const PROVIDER_INFO = {
133
134
  "claude-cli": { label: "Claude Code", subscription: "Claude subscription" },
134
135
  "codex-cli": { label: "Codex", subscription: "ChatGPT subscription" },
135
136
  "cursor-agent": { label: "Cursor Agent", subscription: "Cursor subscription" },
137
+ "kimi-cli": { label: "Kimi CLI", subscription: null },
136
138
  "openai-compat": { label: "Self-hosted / local model (Ollama, vLLM, LM Studio, ...)", subscription: null },
137
139
  deterministic: { label: "Deterministic local fallback", subscription: null },
138
140
  };
@@ -210,7 +212,8 @@ class PromptSynthProvider {
210
212
  /** Run a CLI with the prompt on stdin, stripping API-key env vars so the tool
211
213
  * falls through to its SUBSCRIPTION credentials. Shared by codex/cursor. */
212
214
  async runCli(bin, args, stripEnv, prompt, timeoutMs = 120_000) {
213
- const env = { ...process.env };
215
+ assertInitiatorProvider(this.name);
216
+ const env = initiatorChildEnv();
214
217
  for (const k of stripEnv)
215
218
  delete env[k];
216
219
  const { stdout } = await pexecIn(bin, args, {
@@ -223,6 +226,7 @@ class PromptSynthProvider {
223
226
  return stdout;
224
227
  }
225
228
  async draftDecision(input) {
229
+ assertInitiatorProvider(this.name);
226
230
  const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`, "json");
227
231
  const draft = decisionDraftFromText(text, input.subject);
228
232
  // No usable LLM JSON (truncation, refusal, prose-only, or a CLI whose output
@@ -238,6 +242,7 @@ class PromptSynthProvider {
238
242
  return draft;
239
243
  }
240
244
  async draftBug(input) {
245
+ assertInitiatorProvider(this.name);
241
246
  const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`, "json");
242
247
  const draft = bugDraftFromText(text, input.test, input.message);
243
248
  if (!draft)
@@ -248,6 +253,7 @@ class PromptSynthProvider {
248
253
  * mode required by the record mappers. Throws on empty output so the caller
249
254
  * falls back to its deterministic template page. */
250
255
  async draftProse(prompt) {
256
+ assertInitiatorProvider(this.name);
251
257
  const text = (await this.run(prompt, "text")).trim();
252
258
  if (!text)
253
259
  throw new Error(`${this.name}: empty prose output`);
@@ -258,6 +264,7 @@ class PromptSynthProvider {
258
264
  * Throws on unusable output so verifyDecisionSafe degrades to the un-audited
259
265
  * draft (a verifier failure must never lose the draft — dec_18a81c8291). */
260
266
  async verifyDecision(input, draft) {
267
+ assertInitiatorProvider(this.name);
261
268
  const text = await this.run(`${VERIFY_SYSTEM}\n\n${verifyPrompt(input, draft)}\n\n${jsonInstruction(VERIFY_TOOL.input_schema)}`, "json");
262
269
  const verdict = verdictFromText(text);
263
270
  if (!verdict)
@@ -268,6 +275,7 @@ class PromptSynthProvider {
268
275
  * Uses the provider's guarded transport. Throws on unusable
269
276
  * output so the caller can degrade to a keep-for-human verdict. */
270
277
  async judgeDraft(draft, existing) {
278
+ assertInitiatorProvider(this.name);
271
279
  const text = await this.run(`${RELEVANCE_SYSTEM}\n\n${relevancePrompt(draft, existing)}\n\n${jsonInstruction(RELEVANCE_TOOL.input_schema)}`, "json");
272
280
  const verdict = relevanceFromText(text);
273
281
  if (!verdict)
@@ -690,13 +698,31 @@ export function extractCodexText(out) {
690
698
  return agentTexts[agentTexts.length - 1];
691
699
  return texts.length ? texts[texts.length - 1] : out;
692
700
  }
693
- // This registry is deliberately NOT a priority order. Auto mode only spends a
694
- // subscription when it can identify exactly one usable CLI; see
695
- // resolveSynthesisProvider below.
701
+ // This registry is not a priority order: invocation origin selects the provider.
702
+ class AdapterCliProvider extends PromptSynthProvider {
703
+ name;
704
+ adapters;
705
+ worker;
706
+ constructor(name, adapters = []) {
707
+ super();
708
+ this.name = name;
709
+ this.adapters = adapters;
710
+ }
711
+ async available() {
712
+ this.worker = discoverAgentClis(this.adapters, this.name).find(p => p.name === this.name);
713
+ return !!this.worker;
714
+ }
715
+ async run(prompt) {
716
+ if (!this.worker?.draftProse)
717
+ throw new Error(`Initiating CLI ${this.name} is unavailable`);
718
+ return this.worker.draftProse(prompt);
719
+ }
720
+ }
696
721
  const PROVIDERS = [
697
722
  new ClaudeCliProvider(),
698
723
  new CodexCliProvider(),
699
724
  new CursorCliProvider(),
725
+ new AdapterCliProvider("kimi-cli"),
700
726
  new OpenAICompatProvider(),
701
727
  new DeterministicProvider(),
702
728
  ];
@@ -795,9 +821,8 @@ async function statusesFor(providers) {
795
821
  }
796
822
  return statuses;
797
823
  }
798
- /** Resolve the provider without ever inferring which of several installed products is
799
- * the one the user intends to spend. Precedence is deliberate: a one-shell override,
800
- * then a per-user local preference, then safe auto-detection. */
824
+ /** Respect offline mode, then bind to invocation origin. Explicit terminal preferences
825
+ * apply only without an agent origin; installed executables never select an account. */
801
826
  export async function resolveSynthesisProvider(opts = {}) {
802
827
  const providers = opts.providers ?? PROVIDERS;
803
828
  const env = opts.env ?? process.env;
@@ -809,6 +834,30 @@ export async function resolveSynthesisProvider(opts = {}) {
809
834
  return provider && await isAvailable(provider) ? provider : undefined;
810
835
  };
811
836
  const environment = normalizeProviderName(env.HUNCH_SYNTH_PROVIDER?.trim());
837
+ // Explicit offline/privacy mode always wins: origin binding must never turn it into a model call.
838
+ if (environment === "deterministic")
839
+ return { provider: fallback, source: "environment", preference: "deterministic", statuses };
840
+ if ((!environment || environment === "auto") && opts.root && readSynthesisPreference(opts.root) === "deterministic") {
841
+ return { provider: fallback, source: "local", preference: "deterministic", statuses };
842
+ }
843
+ const origin = currentInitiator(env);
844
+ if (origin.provider) {
845
+ let provider = providers.find(p => p.name === origin.provider);
846
+ const config = opts.cliConfig ?? env.HUNCH_CLI_CONFIG ?? env.HUNCH_REVIEW_CLI_CONFIG;
847
+ if (!provider && !opts.providers && config) {
848
+ const adapters = readAgentCliConfig(config);
849
+ if (adapters.some(a => a.name === origin.provider))
850
+ provider = new AdapterCliProvider(origin.provider, adapters);
851
+ }
852
+ if (provider && await isAvailable(provider)) {
853
+ return { provider, source: "initiator", preference: "auto", statuses, initiator: origin.provider };
854
+ }
855
+ return { provider: fallback, source: "unavailable-initiator", preference: "auto", statuses, initiator: origin.provider };
856
+ }
857
+ // An MCP request with unknown identity must not inherit the launching terminal's preferences.
858
+ if (origin.source === "ambiguous" || origin.source === "client" || env.HUNCH_INITIATOR === "unknown") {
859
+ return { provider: fallback, source: "unknown-initiator", preference: "auto", statuses, initiator: null };
860
+ }
812
861
  if (environment && isSynthPreference(environment) && environment !== "auto") {
813
862
  const selected = await usable(environment);
814
863
  if (selected)
@@ -827,14 +876,9 @@ export async function resolveSynthesisProvider(opts = {}) {
827
876
  return { provider: fallback, source: "unavailable-preference", preference, statuses };
828
877
  }
829
878
  const available = statuses.filter((status) => status.name !== "deterministic" && status.available);
830
- if (available.length === 1) {
831
- const selected = await usable(available[0].name);
832
- if (selected)
833
- return { provider: selected, source: "single-available", preference, statuses };
834
- }
835
879
  return {
836
880
  provider: fallback,
837
- source: available.length > 1 ? "ambiguous" : "none",
881
+ source: available.length > 1 ? "ambiguous" : available.length ? "unknown-initiator" : "none",
838
882
  preference,
839
883
  statuses,
840
884
  };
@@ -844,26 +888,14 @@ export async function resolveSynthesisProvider(opts = {}) {
844
888
  export async function selectProvider(opts = {}) {
845
889
  return (await resolveSynthesisProvider(opts)).provider;
846
890
  }
847
- // ---- Deep Synthesis: ensemble of subscription CLIs (+ opt-in openai-compat) ----
848
- // Opt-in (backfill/sync --deep): fan a commit out to EVERY available worker —
849
- // the subscription CLIs (ANTHROPIC_API_KEY stripping inherited from them) plus the
850
- // opt-in openai-compat HTTP provider when configured, which is outside that
851
- // stripping scope entirely (con_2ce3f2a547 governs the Anthropic API specifically,
852
- // not a user-configured self-hosted endpoint) — drop failures, reconcile the
853
- // drafts. NEVER used on the guard path; confidence is capped below the strict gate
854
- // so output stays advisory.
855
- /** All available subscription-CLI workers (claude/codex/cursor, plus the opt-in
856
- * openai-compat), excluding the deterministic fallback — the pool Deep Synthesis
857
- * fans a commit out to. */
891
+ // ---- Deep Synthesis: repeated samples from the same initiating provider ----
892
+ // Opt-in (backfill/sync --deep): sample the initiating provider repeatedly, drop
893
+ // failures and reconcile drafts. Never used on the guard path; confidence remains
894
+ // capped below the strict gate so output stays advisory.
895
+ /** Only the resolved origin-bound worker. No cross-account fan-out. */
858
896
  export async function selectWorkers(opts = {}) {
859
- const out = [];
860
- for (const p of opts.providers ?? PROVIDERS) {
861
- if (p.name === "deterministic")
862
- continue; // workers are real LLM providers only
863
- if (await isAvailable(p))
864
- out.push(p);
865
- }
866
- return out;
897
+ const { provider } = await resolveSynthesisProvider(opts);
898
+ return provider.name === "deterministic" ? [] : [provider];
867
899
  }
868
900
  const tokens = (d) => new Set(`${d.title} ${d.decision}`.toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []);
869
901
  /** Mean pairwise Jaccard overlap of the drafts' identifying text (0..1) — how much the
@@ -907,9 +939,7 @@ export function mergeDecisionDrafts(drafts) {
907
939
  agreement: Math.round(agreement * 100) / 100,
908
940
  };
909
941
  }
910
- // Default self-consistency depth when only ONE LLM provider is available (the
911
- // common case): sample it this many times and reconcile, so single-provider users get
912
- // ensemble-like robustness. Tunable per-call via `--samples`.
942
+ // Sample the initiating provider this many times and reconcile. Tunable via --samples.
913
943
  const DEFAULT_SAMPLES = 2;
914
944
  export class EnsembleProvider {
915
945
  workers;
@@ -917,6 +947,8 @@ export class EnsembleProvider {
917
947
  samples;
918
948
  constructor(workers, opts = {}) {
919
949
  this.workers = workers;
950
+ for (const worker of workers)
951
+ assertInitiatorProvider(worker.name);
920
952
  // Default 1 (single worker → passthrough); the self-consistency policy default
921
953
  // lives at the selection layer (selectEnsemble). Coerce to a finite integer in a
922
954
  // sane 1..5 band — a NaN here would make decisionTasks build ZERO tasks and throw,
@@ -925,8 +957,8 @@ export class EnsembleProvider {
925
957
  this.samples = Number.isFinite(n) ? Math.max(1, Math.min(5, n)) : 1;
926
958
  }
927
959
  async available() { return this.workers.length > 0; }
928
- /** The draft tasks to fan out: one per distinct CLI when several are installed
929
- * (cross-model ensemble), else N self-consistency samples of the single CLI. */
960
+ /** Production selection supplies one origin-bound worker with N samples.
961
+ * Direct callers can also supply multiple workers subject to origin checks. */
930
962
  decisionTasks(input) {
931
963
  if (this.workers.length >= 2)
932
964
  return this.workers.map((w) => () => w.draftDecision(input));
@@ -131,7 +131,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
131
131
  const provider = localOnly
132
132
  ? new DeterministicProvider()
133
133
  : opts.deep
134
- ? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider({ root })
134
+ ? (await selectEnsemble({ root, samples: opts.samples })) ?? await selectProvider({ root })
135
135
  : opts.force || opts.verify || isSignificant(meta, analysis, substantiveFiles)
136
136
  ? await selectProvider({ root })
137
137
  : new DeterministicProvider();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.30.0",
3
+ "version": "1.31.1",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
@@ -94,6 +94,7 @@
94
94
  "@modelcontextprotocol/sdk": "^1.30.0",
95
95
  "@tree-sitter-grammars/tree-sitter-yaml": "^0.6.1",
96
96
  "commander": "^15.0.0",
97
+ "cross-spawn": "^7.0.6",
97
98
  "smol-toml": "1.8.0",
98
99
  "tree-sitter": "0.21.1",
99
100
  "tree-sitter-go": "^0.23.4",
@@ -103,6 +104,7 @@
103
104
  "zod": "^4.4.3"
104
105
  },
105
106
  "devDependencies": {
107
+ "@types/cross-spawn": "^6.0.6",
106
108
  "@types/node": "^22.13.0",
107
109
  "tsx": "^4.22.4",
108
110
  "typescript": "^5.9.3"
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.30.0",
10
+ "version": "1.31.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.30.0",
16
+ "version": "1.31.1",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {