@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.
@@ -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
@@ -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 uses a subscription only when exactly one usable CLI is found.
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
- const env = { ...process.env };
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 || "Code change",
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)} code in ${dirs.join(", ") || "the repo"} (${input.files.length} file(s)).`,
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 deliberately NOT a priority order. Auto mode only spends a
690
- // subscription when it can identify exactly one usable CLI; see
691
- // 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
+ }
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
- /** Resolve the provider without ever inferring which of several installed products is
795
- * the one the user intends to spend. Precedence is deliberate: a one-shell override,
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: ensemble of subscription CLIs (+ opt-in openai-compat) ----
844
- // Opt-in (backfill/sync --deep): fan a commit out to EVERY available worker —
845
- // the subscription CLIs (ANTHROPIC_API_KEY stripping inherited from them) plus the
846
- // opt-in openai-compat HTTP provider when configured, which is outside that
847
- // stripping scope entirely (con_2ce3f2a547 governs the Anthropic API specifically,
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 out = [];
856
- for (const p of opts.providers ?? PROVIDERS) {
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
- // Default self-consistency depth when only ONE LLM provider is available (the
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
- /** The draft tasks to fan out: one per distinct CLI when several are installed
925
- * (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. */
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 { languageFor } from "../extractors/languages.js";
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
- export function isSignificant(meta, a, codeFiles) {
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 (codeFiles.length >= 3)
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
- const codeFiles = meta.files.filter((f) => languageFor(f) !== null);
54
- if (codeFiles.length === 0)
55
- return { status: "skipped", reason: "no code files changed" };
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(codeFiles, meta.subject, store.recs("decisions"), Date.now());
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, codeFiles)
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: codeFiles, diff, analysis };
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) => codeFiles.some((f) => c.paths.some((g) => pathMatchesGlob(f, g))))
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) => codeFiles.some((f) => c.scope.some((g) => pathMatchesGlob(f, g))));
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, codeFiles, knownRepoDeps(root)),
207
+ : draftTripwires(draft.alternatives_rejected, substantiveFiles, knownRepoDeps(root)),
187
208
  related_components: relatedComponents,
188
- related_files: codeFiles,
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, ...codeFiles.slice(0, 8)],
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.29.0",
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.29.0",
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.29.0",
16
+ "version": "1.31.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {