@davesheffer/hunch 1.29.0 → 1.31.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/README.md +9 -0
- package/dist/cli/automaticReviewMemory.js +124 -0
- package/dist/cli/index.js +80 -10
- package/dist/cli/invocation.js +9 -0
- package/dist/cli/reviewMemory.js +34 -0
- package/dist/cli/reviewMemoryProvider.js +40 -0
- package/dist/cli/serve.js +44 -0
- package/dist/constitution/experimentRunner.js +3 -1
- package/dist/core/automaticReviewMemory.js +141 -0
- package/dist/core/reviewMemory.js +100 -0
- package/dist/core/stateContract.js +9 -0
- package/dist/core/stateRecords.js +30 -0
- package/dist/extractors/diff.js +26 -21
- package/dist/extractors/git.js +7 -1
- package/dist/extractors/languages.js +8 -0
- package/dist/mcp/server.js +53 -44
- package/dist/store/replay.js +153 -0
- package/dist/store/stateBinding.js +160 -12
- package/dist/synthesis/cliAdapter.js +168 -0
- package/dist/synthesis/initiator.js +58 -0
- package/dist/synthesis/provider.js +84 -48
- package/dist/synthesis/synthesize.js +37 -16
- 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
|
|
@@ -31,6 +30,9 @@ import { tmpdir } from "node:os";
|
|
|
31
30
|
import { dirname, join } from "node:path";
|
|
32
31
|
import { writeFileAtomic } from "../core/io.js";
|
|
33
32
|
import { summarizeDiff } from "../extractors/diff.js";
|
|
33
|
+
import { languageFor } from "../extractors/languages.js";
|
|
34
|
+
import { assertInitiatorProvider, currentInitiator, initiatorChildEnv } from "./initiator.js";
|
|
35
|
+
import { discoverAgentClis, readAgentCliConfig } from "./cliAdapter.js";
|
|
34
36
|
const IS_WIN = process.platform === "win32";
|
|
35
37
|
/**
|
|
36
38
|
* Run a command, optionally feeding `input` to its stdin, and resolve its
|
|
@@ -52,12 +54,12 @@ export function pexecIn(cmd, args, opts = {}) {
|
|
|
52
54
|
const child = IS_WIN
|
|
53
55
|
? spawn([cmd, ...args].join(" "), {
|
|
54
56
|
shell: true,
|
|
55
|
-
env: opts.env,
|
|
57
|
+
env: initiatorChildEnv(opts.env ?? process.env),
|
|
56
58
|
cwd: opts.cwd,
|
|
57
59
|
windowsHide: true,
|
|
58
60
|
})
|
|
59
61
|
: spawn(cmd, args, {
|
|
60
|
-
env: opts.env,
|
|
62
|
+
env: initiatorChildEnv(opts.env ?? process.env),
|
|
61
63
|
cwd: opts.cwd,
|
|
62
64
|
windowsHide: true,
|
|
63
65
|
});
|
|
@@ -123,15 +125,16 @@ export function pexecIn(cmd, args, opts = {}) {
|
|
|
123
125
|
});
|
|
124
126
|
}
|
|
125
127
|
/** Every selectable synthesis mode. `auto` is a preference value rather than a
|
|
126
|
-
* provider: it
|
|
128
|
+
* provider: it resolves the invocation's origin without choosing by availability.
|
|
127
129
|
* "openai-compat" is the opt-in local/self-hosted HTTP provider (Ollama, vLLM,
|
|
128
130
|
* LM Studio, ...) — not a subscription, but explicitly selectable like one. */
|
|
129
|
-
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"];
|
|
130
132
|
export const SYNTH_PREFERENCES = ["auto", ...SYNTH_PROVIDER_NAMES];
|
|
131
133
|
const PROVIDER_INFO = {
|
|
132
134
|
"claude-cli": { label: "Claude Code", subscription: "Claude subscription" },
|
|
133
135
|
"codex-cli": { label: "Codex", subscription: "ChatGPT subscription" },
|
|
134
136
|
"cursor-agent": { label: "Cursor Agent", subscription: "Cursor subscription" },
|
|
137
|
+
"kimi-cli": { label: "Kimi CLI", subscription: null },
|
|
135
138
|
"openai-compat": { label: "Self-hosted / local model (Ollama, vLLM, LM Studio, ...)", subscription: null },
|
|
136
139
|
deterministic: { label: "Deterministic local fallback", subscription: null },
|
|
137
140
|
};
|
|
@@ -209,7 +212,8 @@ class PromptSynthProvider {
|
|
|
209
212
|
/** Run a CLI with the prompt on stdin, stripping API-key env vars so the tool
|
|
210
213
|
* falls through to its SUBSCRIPTION credentials. Shared by codex/cursor. */
|
|
211
214
|
async runCli(bin, args, stripEnv, prompt, timeoutMs = 120_000) {
|
|
212
|
-
|
|
215
|
+
assertInitiatorProvider(this.name);
|
|
216
|
+
const env = initiatorChildEnv();
|
|
213
217
|
for (const k of stripEnv)
|
|
214
218
|
delete env[k];
|
|
215
219
|
const { stdout } = await pexecIn(bin, args, {
|
|
@@ -222,6 +226,7 @@ class PromptSynthProvider {
|
|
|
222
226
|
return stdout;
|
|
223
227
|
}
|
|
224
228
|
async draftDecision(input) {
|
|
229
|
+
assertInitiatorProvider(this.name);
|
|
225
230
|
const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`, "json");
|
|
226
231
|
const draft = decisionDraftFromText(text, input.subject);
|
|
227
232
|
// No usable LLM JSON (truncation, refusal, prose-only, or a CLI whose output
|
|
@@ -237,6 +242,7 @@ class PromptSynthProvider {
|
|
|
237
242
|
return draft;
|
|
238
243
|
}
|
|
239
244
|
async draftBug(input) {
|
|
245
|
+
assertInitiatorProvider(this.name);
|
|
240
246
|
const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`, "json");
|
|
241
247
|
const draft = bugDraftFromText(text, input.test, input.message);
|
|
242
248
|
if (!draft)
|
|
@@ -247,6 +253,7 @@ class PromptSynthProvider {
|
|
|
247
253
|
* mode required by the record mappers. Throws on empty output so the caller
|
|
248
254
|
* falls back to its deterministic template page. */
|
|
249
255
|
async draftProse(prompt) {
|
|
256
|
+
assertInitiatorProvider(this.name);
|
|
250
257
|
const text = (await this.run(prompt, "text")).trim();
|
|
251
258
|
if (!text)
|
|
252
259
|
throw new Error(`${this.name}: empty prose output`);
|
|
@@ -257,6 +264,7 @@ class PromptSynthProvider {
|
|
|
257
264
|
* Throws on unusable output so verifyDecisionSafe degrades to the un-audited
|
|
258
265
|
* draft (a verifier failure must never lose the draft — dec_18a81c8291). */
|
|
259
266
|
async verifyDecision(input, draft) {
|
|
267
|
+
assertInitiatorProvider(this.name);
|
|
260
268
|
const text = await this.run(`${VERIFY_SYSTEM}\n\n${verifyPrompt(input, draft)}\n\n${jsonInstruction(VERIFY_TOOL.input_schema)}`, "json");
|
|
261
269
|
const verdict = verdictFromText(text);
|
|
262
270
|
if (!verdict)
|
|
@@ -267,6 +275,7 @@ class PromptSynthProvider {
|
|
|
267
275
|
* Uses the provider's guarded transport. Throws on unusable
|
|
268
276
|
* output so the caller can degrade to a keep-for-human verdict. */
|
|
269
277
|
async judgeDraft(draft, existing) {
|
|
278
|
+
assertInitiatorProvider(this.name);
|
|
270
279
|
const text = await this.run(`${RELEVANCE_SYSTEM}\n\n${relevancePrompt(draft, existing)}\n\n${jsonInstruction(RELEVANCE_TOOL.input_schema)}`, "json");
|
|
271
280
|
const verdict = relevanceFromText(text);
|
|
272
281
|
if (!verdict)
|
|
@@ -614,6 +623,9 @@ export class DeterministicProvider {
|
|
|
614
623
|
const dirs = topDirs(input.files);
|
|
615
624
|
const a = input.analysis;
|
|
616
625
|
const summary = a ? summarizeDiff(a) : "";
|
|
626
|
+
// input.files can be markdown-only (issue #12) — don't claim "code" for a
|
|
627
|
+
// commit that touched none.
|
|
628
|
+
const noun = input.files.some((f) => languageFor(f) !== null) ? "code" : "content";
|
|
617
629
|
const verb = /^(add|introduce|create|feat)/i.test(input.subject) ? "introduced"
|
|
618
630
|
: /^(remove|delete|drop)/i.test(input.subject) ? "removed"
|
|
619
631
|
: /^(refactor|rework|restructure)/i.test(input.subject) ? "refactored"
|
|
@@ -630,12 +642,12 @@ export class DeterministicProvider {
|
|
|
630
642
|
// We extracted real structure → a bit more trustworthy than a blind heuristic.
|
|
631
643
|
const informative = !!(a && (a.addedSymbols.length || a.removedSymbols.length || a.changedSymbols.length || a.addedDeps.length || a.removedDeps.length));
|
|
632
644
|
return {
|
|
633
|
-
title: input.subject ||
|
|
645
|
+
title: input.subject || `${cap(noun)} change`,
|
|
634
646
|
context: [input.body, summary && `What changed: ${summary}.`].filter(Boolean).join(" ").slice(0, 500)
|
|
635
647
|
|| `Touched ${input.files.length} file(s) across ${dirs.join(", ") || "the repo"}.`,
|
|
636
648
|
decision: summary
|
|
637
649
|
? `${cap(verb)} ${dirs.join(", ") || "the repo"}: ${summary}.`
|
|
638
|
-
: `${cap(verb)}
|
|
650
|
+
: `${cap(verb)} ${noun} in ${dirs.join(", ") || "the repo"} (${input.files.length} file(s)).`,
|
|
639
651
|
consequences,
|
|
640
652
|
alternatives_rejected: [],
|
|
641
653
|
// advisory either way, but real extraction earns a touch more confidence
|
|
@@ -686,13 +698,31 @@ export function extractCodexText(out) {
|
|
|
686
698
|
return agentTexts[agentTexts.length - 1];
|
|
687
699
|
return texts.length ? texts[texts.length - 1] : out;
|
|
688
700
|
}
|
|
689
|
-
// This registry is
|
|
690
|
-
|
|
691
|
-
|
|
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
|
+
}
|
|
692
721
|
const PROVIDERS = [
|
|
693
722
|
new ClaudeCliProvider(),
|
|
694
723
|
new CodexCliProvider(),
|
|
695
724
|
new CursorCliProvider(),
|
|
725
|
+
new AdapterCliProvider("kimi-cli"),
|
|
696
726
|
new OpenAICompatProvider(),
|
|
697
727
|
new DeterministicProvider(),
|
|
698
728
|
];
|
|
@@ -791,9 +821,8 @@ async function statusesFor(providers) {
|
|
|
791
821
|
}
|
|
792
822
|
return statuses;
|
|
793
823
|
}
|
|
794
|
-
/**
|
|
795
|
-
*
|
|
796
|
-
* 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. */
|
|
797
826
|
export async function resolveSynthesisProvider(opts = {}) {
|
|
798
827
|
const providers = opts.providers ?? PROVIDERS;
|
|
799
828
|
const env = opts.env ?? process.env;
|
|
@@ -805,6 +834,30 @@ export async function resolveSynthesisProvider(opts = {}) {
|
|
|
805
834
|
return provider && await isAvailable(provider) ? provider : undefined;
|
|
806
835
|
};
|
|
807
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
|
+
}
|
|
808
861
|
if (environment && isSynthPreference(environment) && environment !== "auto") {
|
|
809
862
|
const selected = await usable(environment);
|
|
810
863
|
if (selected)
|
|
@@ -823,14 +876,9 @@ export async function resolveSynthesisProvider(opts = {}) {
|
|
|
823
876
|
return { provider: fallback, source: "unavailable-preference", preference, statuses };
|
|
824
877
|
}
|
|
825
878
|
const available = statuses.filter((status) => status.name !== "deterministic" && status.available);
|
|
826
|
-
if (available.length === 1) {
|
|
827
|
-
const selected = await usable(available[0].name);
|
|
828
|
-
if (selected)
|
|
829
|
-
return { provider: selected, source: "single-available", preference, statuses };
|
|
830
|
-
}
|
|
831
879
|
return {
|
|
832
880
|
provider: fallback,
|
|
833
|
-
source: available.length > 1 ? "ambiguous" : "none",
|
|
881
|
+
source: available.length > 1 ? "ambiguous" : available.length ? "unknown-initiator" : "none",
|
|
834
882
|
preference,
|
|
835
883
|
statuses,
|
|
836
884
|
};
|
|
@@ -840,26 +888,14 @@ export async function resolveSynthesisProvider(opts = {}) {
|
|
|
840
888
|
export async function selectProvider(opts = {}) {
|
|
841
889
|
return (await resolveSynthesisProvider(opts)).provider;
|
|
842
890
|
}
|
|
843
|
-
// ---- Deep Synthesis:
|
|
844
|
-
// Opt-in (backfill/sync --deep):
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
|
|
848
|
-
// not a user-configured self-hosted endpoint) — drop failures, reconcile the
|
|
849
|
-
// drafts. NEVER used on the guard path; confidence is capped below the strict gate
|
|
850
|
-
// so output stays advisory.
|
|
851
|
-
/** All available subscription-CLI workers (claude/codex/cursor, plus the opt-in
|
|
852
|
-
* openai-compat), excluding the deterministic fallback — the pool Deep Synthesis
|
|
853
|
-
* 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. */
|
|
854
896
|
export async function selectWorkers(opts = {}) {
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
if (p.name === "deterministic")
|
|
858
|
-
continue; // workers are real LLM providers only
|
|
859
|
-
if (await isAvailable(p))
|
|
860
|
-
out.push(p);
|
|
861
|
-
}
|
|
862
|
-
return out;
|
|
897
|
+
const { provider } = await resolveSynthesisProvider(opts);
|
|
898
|
+
return provider.name === "deterministic" ? [] : [provider];
|
|
863
899
|
}
|
|
864
900
|
const tokens = (d) => new Set(`${d.title} ${d.decision}`.toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []);
|
|
865
901
|
/** Mean pairwise Jaccard overlap of the drafts' identifying text (0..1) — how much the
|
|
@@ -903,9 +939,7 @@ export function mergeDecisionDrafts(drafts) {
|
|
|
903
939
|
agreement: Math.round(agreement * 100) / 100,
|
|
904
940
|
};
|
|
905
941
|
}
|
|
906
|
-
//
|
|
907
|
-
// common case): sample it this many times and reconcile, so single-provider users get
|
|
908
|
-
// ensemble-like robustness. Tunable per-call via `--samples`.
|
|
942
|
+
// Sample the initiating provider this many times and reconcile. Tunable via --samples.
|
|
909
943
|
const DEFAULT_SAMPLES = 2;
|
|
910
944
|
export class EnsembleProvider {
|
|
911
945
|
workers;
|
|
@@ -913,6 +947,8 @@ export class EnsembleProvider {
|
|
|
913
947
|
samples;
|
|
914
948
|
constructor(workers, opts = {}) {
|
|
915
949
|
this.workers = workers;
|
|
950
|
+
for (const worker of workers)
|
|
951
|
+
assertInitiatorProvider(worker.name);
|
|
916
952
|
// Default 1 (single worker → passthrough); the self-consistency policy default
|
|
917
953
|
// lives at the selection layer (selectEnsemble). Coerce to a finite integer in a
|
|
918
954
|
// sane 1..5 band — a NaN here would make decisionTasks build ZERO tasks and throw,
|
|
@@ -921,8 +957,8 @@ export class EnsembleProvider {
|
|
|
921
957
|
this.samples = Number.isFinite(n) ? Math.max(1, Math.min(5, n)) : 1;
|
|
922
958
|
}
|
|
923
959
|
async available() { return this.workers.length > 0; }
|
|
924
|
-
/**
|
|
925
|
-
*
|
|
960
|
+
/** Production selection supplies one origin-bound worker with N samples.
|
|
961
|
+
* Direct callers can also supply multiple workers subject to origin checks. */
|
|
926
962
|
decisionTasks(input) {
|
|
927
963
|
if (this.workers.length >= 2)
|
|
928
964
|
return this.workers.map((w) => () => w.draftDecision(input));
|
|
@@ -5,7 +5,7 @@ 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
|
-
import {
|
|
8
|
+
import { isSubstantive } from "../extractors/languages.js";
|
|
9
9
|
// "chore(deps):" is anchored separately (not via \b) because \b requires a
|
|
10
10
|
// word/non-word transition, and the character after the closing ")" is ":" or a
|
|
11
11
|
// space — both non-word — so no boundary ever fires there.
|
|
@@ -26,14 +26,16 @@ export function isTrivialSubject(meta) {
|
|
|
26
26
|
* deterministic. Any structural change (symbol/dependency delta), non-trivial
|
|
27
27
|
* churn, several files, OR an explanatory commit body signals a real decision
|
|
28
28
|
* worth the model. Everything below (typo/tweak/one-liner with no message) falls
|
|
29
|
-
* to the free deterministic draft — shallower but honestly low-confidence.
|
|
30
|
-
|
|
29
|
+
* to the free deterministic draft — shallower but honestly low-confidence.
|
|
30
|
+
* `files` is whatever the caller is synthesizing from (code and/or markdown, per
|
|
31
|
+
* isSubstantive) — the line/file-count checks are format-agnostic. */
|
|
32
|
+
export function isSignificant(meta, a, files) {
|
|
31
33
|
const structural = a.addedSymbols.length + a.removedSymbols.length + a.changedSymbols.length + a.addedDeps.length + a.removedDeps.length;
|
|
32
34
|
if (structural > 0)
|
|
33
35
|
return true;
|
|
34
36
|
if (a.addedLines + a.removedLines >= SIG_MIN_LINES)
|
|
35
37
|
return true;
|
|
36
|
-
if (
|
|
38
|
+
if (files.length >= 3)
|
|
37
39
|
return true;
|
|
38
40
|
if (meta.body.trim().length >= SIG_MIN_BODY)
|
|
39
41
|
return true;
|
|
@@ -50,9 +52,28 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
50
52
|
return { status: "skipped", reason: "commit not found" };
|
|
51
53
|
if (isTrivialSubject(meta))
|
|
52
54
|
return { status: "skipped", reason: `trivial subject: ${meta.subject}` };
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// .hunch/** is Hunch's OWN store — commitDiff already excludes it from the diff
|
|
56
|
+
// CONTENT via DIFF_NOISE ("circular noise": re-synthesizing a commit that wrote
|
|
57
|
+
// it would draft a decision about Hunch's own bookkeeping). That guard never
|
|
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.
|
|
67
|
+
if (meta.files.some((f) => pathMatchesGlob(f, "**/.hunch/**"))) {
|
|
68
|
+
return { status: "skipped", reason: "touches Hunch's own store (.hunch/**) — circular, never synthesis input" };
|
|
69
|
+
}
|
|
70
|
+
// Substantive, not just parseable: markdown carries the *why* in a docs/ADR repo
|
|
71
|
+
// just as legitimately as a .ts diff does, even though it has no symbol graph
|
|
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)
|
|
76
|
+
return { status: "skipped", reason: "no code or markdown files changed" };
|
|
56
77
|
// Seed the id from the COMMIT (stable across runs), not the LLM-generated title
|
|
57
78
|
// (which varies) — so re-syncing a commit updates rather than dupes.
|
|
58
79
|
const id = decisionId(meta.sha);
|
|
@@ -83,7 +104,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
83
104
|
// review triage measured 7 of 14 queued drafts as exactly this. A recent
|
|
84
105
|
// human-confirmed decision claiming this commit's files → skip the draft (and
|
|
85
106
|
// the subscription call). Recency-windowed; --force overrides.
|
|
86
|
-
const covered = commitCoveredBy(
|
|
107
|
+
const covered = commitCoveredBy(substantiveFiles, meta.subject, store.recs("decisions"), Date.now());
|
|
87
108
|
if (covered && !opts.force) {
|
|
88
109
|
return {
|
|
89
110
|
status: "skipped",
|
|
@@ -110,11 +131,11 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
110
131
|
const provider = localOnly
|
|
111
132
|
? new DeterministicProvider()
|
|
112
133
|
: opts.deep
|
|
113
|
-
? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider({ root })
|
|
114
|
-
: opts.force || opts.verify || isSignificant(meta, analysis,
|
|
134
|
+
? (await selectEnsemble({ root, samples: opts.samples })) ?? await selectProvider({ root })
|
|
135
|
+
: opts.force || opts.verify || isSignificant(meta, analysis, substantiveFiles)
|
|
115
136
|
? await selectProvider({ root })
|
|
116
137
|
: new DeterministicProvider();
|
|
117
|
-
const input = { subject: meta.subject, body: meta.body, files:
|
|
138
|
+
const input = { subject: meta.subject, body: meta.body, files: substantiveFiles, diff, analysis };
|
|
118
139
|
let draft = await draftDecisionSafe(provider, input);
|
|
119
140
|
// The Critic pass: audit the draft against the commit, PRUNE unsupported alternatives
|
|
120
141
|
// (BEFORE they scaffold tripwires below) and consequences, and lower confidence on weak
|
|
@@ -150,13 +171,13 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
150
171
|
const branchTag = branch ? [`branch:${branch}`] : [];
|
|
151
172
|
const components = store.json.loadAll("components");
|
|
152
173
|
const relatedComponents = components
|
|
153
|
-
.filter((c) =>
|
|
174
|
+
.filter((c) => substantiveFiles.some((f) => c.paths.some((g) => pathMatchesGlob(f, g))))
|
|
154
175
|
.map((c) => c.id);
|
|
155
176
|
// Surface any do-not-break constraints this commit's files touch (DESIGN §4
|
|
156
177
|
// "constraint touched" flag) right in the decision context, with evidence.
|
|
157
178
|
const touchedConstraints = store.json
|
|
158
179
|
.loadAll("constraints")
|
|
159
|
-
.filter((c) =>
|
|
180
|
+
.filter((c) => substantiveFiles.some((f) => c.scope.some((g) => pathMatchesGlob(f, g))));
|
|
160
181
|
const constraintNote = touchedConstraints.length
|
|
161
182
|
? ` Touches invariant(s): ${touchedConstraints.map((c) => `${c.id} (${c.statement})`).join("; ")}.`
|
|
162
183
|
: "";
|
|
@@ -183,9 +204,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
183
204
|
// → never block until confirmed via `hunch review --accept` (dec_a466655539).
|
|
184
205
|
rejected_tripwires: existing?.rejected_tripwires?.length
|
|
185
206
|
? existing.rejected_tripwires
|
|
186
|
-
: draftTripwires(draft.alternatives_rejected,
|
|
207
|
+
: draftTripwires(draft.alternatives_rejected, substantiveFiles, knownRepoDeps(root)),
|
|
187
208
|
related_components: relatedComponents,
|
|
188
|
-
related_files:
|
|
209
|
+
related_files: substantiveFiles,
|
|
189
210
|
supersedes: existing?.supersedes ?? null,
|
|
190
211
|
superseded_by: existing?.superseded_by ?? null,
|
|
191
212
|
caused_by_bug: existing?.caused_by_bug ?? null,
|
|
@@ -201,7 +222,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
201
222
|
provenance: {
|
|
202
223
|
source: draft.source,
|
|
203
224
|
confidence: draft.confidence,
|
|
204
|
-
evidence: [`commit:${meta.shortSha}`, synthEvidence, ...branchTag, ...
|
|
225
|
+
evidence: [`commit:${meta.shortSha}`, synthEvidence, ...branchTag, ...substantiveFiles.slice(0, 8)],
|
|
205
226
|
last_verified: new Date().toISOString(), // when the Hunch last re-derived this
|
|
206
227
|
},
|
|
207
228
|
date: meta.date, // the commit date
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
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.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.31.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|