@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.
- package/README.md +3 -6
- package/dist/cli/automaticReviewMemory.js +124 -0
- package/dist/cli/index.js +37 -4
- package/dist/cli/invocation.js +9 -0
- package/dist/cli/reviewMemory.js +3 -1
- package/dist/cli/reviewMemoryProvider.js +40 -0
- package/dist/client/state.js +2 -0
- package/dist/constitution/experimentRunner.js +3 -1
- package/dist/core/automaticReviewMemory.js +141 -0
- package/dist/core/stateContract.js +87 -4
- package/dist/core/stateRecords.js +16 -1
- package/dist/extractors/git.js +3 -1
- package/dist/mcp/server.js +42 -4
- package/dist/serve/app.js +20 -0
- package/dist/store/changeLedger.js +40 -9
- package/dist/store/hunchStore.js +7 -2
- package/dist/store/jsonStore.js +15 -0
- package/dist/store/stateBinding.js +110 -15
- package/dist/store/stateCapture.js +145 -0
- package/dist/synthesis/cliAdapter.js +168 -0
- package/dist/synthesis/initiator.js +58 -0
- package/dist/synthesis/provider.js +78 -46
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +3 -1
- package/server.json +2 -2
|
@@ -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.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
/**
|
|
799
|
-
*
|
|
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:
|
|
848
|
-
// Opt-in (backfill/sync --deep):
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
|
|
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
|
|
860
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
929
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
16
|
+
"version": "1.31.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|