@davesheffer/hunch 1.5.0 → 1.7.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 CHANGED
@@ -31,7 +31,7 @@ That gap is where architectural drift starts:
31
31
  | Without Hunch | With Hunch |
32
32
  | --- | --- |
33
33
  | A refactor passes tests but bypasses a hard-won service boundary. | The change is checked against the decision, its constraint, and the incident behind it. |
34
- | A new coding session starts from scratch. | Claude Code, Cursor, Copilot, Windsurf, and Codex retrieve the same project memory over MCP. |
34
+ | A new coding session starts from scratch. | Claude Code, Cursor, Copilot, Windsurf, Antigravity, and Codex retrieve the same project memory over MCP. |
35
35
  | A correction disappears into a chat transcript. | “Never do that again” becomes a scoped, auditable guard. |
36
36
  | Code review sees a diff, not the reason behind it. | Change Gate produces a PASS / WARN / BLOCK receipt with causal evidence. |
37
37
 
@@ -55,18 +55,26 @@ and an optional pull-request guard.
55
55
 
56
56
  ## One graph. Every assistant. No lock-in.
57
57
 
58
- Hunch is agent-agnostic by design. It scaffolds MCP and grounding for Claude Code, Cursor, VS Code
59
- / Copilot, Windsurf, Codex, and any agent that can read `AGENTS.md`.
58
+ Hunch is agent-agnostic by design. It scaffolds MCP and grounding for Claude Code, Cursor, VS Code / Copilot,
59
+ Windsurf, Google Antigravity, Codex, and any agent that can read `AGENTS.md`; where a client exposes hooks,
60
+ it adds a native lifecycle adapter too.
60
61
 
61
62
  Your memory is plain JSON that you own. Hunch adds a SQLite index only as a rebuildable derived
62
63
  layer—your decisions never disappear into a proprietary hosted memory system.
63
64
 
65
+ Synthesis is just as portable: Hunch can use Claude Code, Codex, or Cursor through the subscription
66
+ CLI you choose. It never guesses which of several installed subscriptions to bill—set your local,
67
+ gitignored preference with `hunch provider codex-cli` (or `claude-cli` / `cursor-agent`); otherwise
68
+ Hunch uses a subscription only when exactly one is available, and falls back to deterministic local
69
+ drafting when the choice is ambiguous.
70
+
64
71
  ```text
65
- Claude Code ─┐
66
- Cursor ├── MCP ──> .hunch/ reasoning graph ──> deterministic checks
67
- Copilot
68
- Codex
69
- Windsurf ─┘
72
+ Claude Code ─┐
73
+ Cursor ├── MCP ──> .hunch/ reasoning graph ──> deterministic checks
74
+ Copilot
75
+ Codex
76
+ Windsurf
77
+ Antigravity ┘
70
78
  ```
71
79
 
72
80
  ## The Change Gate: review intent, not just code
package/dist/cli/index.js CHANGED
@@ -28,7 +28,7 @@ import { selectEmbedder } from "../store/embedder.js";
28
28
  import { indexRepo } from "../extractors/indexer.js";
29
29
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
30
30
  import { parseTestReport } from "../extractors/testreport.js";
31
- import { selectProvider } from "../synthesis/provider.js";
31
+ import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, } from "../synthesis/provider.js";
32
32
  import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
33
33
  import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
34
34
  import { runbookId, decisionId } from "../core/ids.js";
@@ -50,6 +50,7 @@ import { formatContext, formatStructure } from "../core/format.js";
50
50
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
51
51
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
52
52
  import { injectionMode } from "../core/hookcache.js";
53
+ import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
53
54
  import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
54
55
  import { draftDuplicateOf } from "../core/dupdetect.js";
55
56
  import { planAutoReview, planMutations } from "../core/autoreview.js";
@@ -83,12 +84,12 @@ function storeFor() {
83
84
  // ---- init -----------------------------------------------------------------
84
85
  program
85
86
  .command("init")
86
- .description("Scaffold .hunch/, index the repo, install the git hook, and wire up your coding assistants (Claude Code, Cursor, VS Code, Windsurf, Codex).")
87
+ .description("Scaffold .hunch/, index the repo, install the git hook, and wire up your coding assistants (Claude Code, Cursor, VS Code, Windsurf, Antigravity, Codex).")
87
88
  .option("--no-index", "skip the initial repo index")
88
89
  .option("--no-enforce", "do not install the advisory pre-commit constraint guard")
89
90
  .option("--enforce-strict", "make the pre-commit guard FAIL the commit on a direct, high-confidence, non-stale blocking invariant")
90
- .option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
91
- .option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
91
+ .option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Windsurf / Antigravity / Codex / AGENTS.md)")
92
+ .option("--no-agent-hooks", "skip installing all assistant lifecycle hooks (MCP + grounding are still configured)")
92
93
  .option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
93
94
  .option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
94
95
  .option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
@@ -174,16 +175,16 @@ program
174
175
  // Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
175
176
  // level even before the user runs `hunch firmness` (--firmness validated above).
176
177
  const firmness = writeConfig(paths, opts.firmness ? { firmness: opts.firmness } : {}).firmness;
177
- // Agent hooks: ground the assistant in Hunch automatically (PreToolUse injects
178
- // context before edits; UserPromptSubmit reminds). Reads firmness at run time.
178
+ // Claude's native hooks run alongside provider-specific hooks below. Every
179
+ // adapter reads firmness at run time, so changing it needs no config rewrite.
179
180
  if (opts.agentHooks !== false) {
180
181
  const a = installClaudeHooks(root, `${inv.shell} hook`);
181
182
  console.log(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
182
183
  }
183
- // Multi-assistant compatibility: the MCP server is client-agnostic, so wire up
184
- // Cursor / VS Code (Copilot) / Codex / AGENTS.md to the same .hunch/ graph.
184
+ // Multi-assistant compatibility: MCP + grounding + lifecycle adapters share
185
+ // the same .hunch/ graph across Cursor / VS Code / Windsurf / Antigravity.
185
186
  if (opts.providers !== false) {
186
- const ps = scaffoldProviders(root, inv.mcp, store);
187
+ const ps = scaffoldProviders(root, inv.mcp, store, { agentHooks: opts.agentHooks !== false });
187
188
  const ok = ps.filter((p) => !p.error);
188
189
  const total = ok.reduce((a, p) => a + p.files.length, 0);
189
190
  console.log(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
@@ -444,7 +445,20 @@ function configureOverlay(dir, opts, mode) {
444
445
  // a store elsewhere on disk. Resolution (env || local.json) re-resolves against root.
445
446
  const rel = relative(root, hunchDir);
446
447
  const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
447
- writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored, autoCommit: !!opts.autoCommit, mode }, null, 2) + "\n");
448
+ const localFile = join(paths.hunch, "local.json");
449
+ let existingLocal = {};
450
+ if (existsSync(localFile)) {
451
+ try {
452
+ const parsed = JSON.parse(readFileSync(localFile, "utf8"));
453
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object")
454
+ throw new Error("not an object");
455
+ existingLocal = parsed;
456
+ }
457
+ catch {
458
+ return fail(`refusing to overwrite malformed local configuration: ${localFile}`);
459
+ }
460
+ }
461
+ writeFileAtomic(localFile, JSON.stringify({ ...existingLocal, privateDir: stored, autoCommit: !!opts.autoCommit, mode }, null, 2) + "\n");
448
462
  ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
449
463
  // SHARED mode with a remote: publish the store's URL in a COMMITTED team.json, so a
450
464
  // fresh clone / new teammate / headless agent auto-connects on `hunch init` (or MCP
@@ -1527,7 +1541,7 @@ program
1527
1541
  // ---- firmness (agent-hook enforcement level) ------------------------------
1528
1542
  program
1529
1543
  .command("firmness")
1530
- .description("Get or set how firmly the Claude Code agent hook enforces Hunch before edits.")
1544
+ .description("Get or set how firmly agent lifecycle hooks enforce Hunch before edits.")
1531
1545
  .argument("[level]", "off | advisory | firm | strict (omit to print the current level)")
1532
1546
  .action((level) => {
1533
1547
  const paths = hunchPaths(findRoot());
@@ -1540,7 +1554,49 @@ program
1540
1554
  return fail(`firmness must be one of: ${FIRMNESS_LEVELS.join(", ")}`);
1541
1555
  }
1542
1556
  const next = writeConfig(paths, { firmness: level }).firmness;
1543
- console.log(`✓ firmness set to ${next} (takes effect on the next edit — no Claude Code restart needed).`);
1557
+ console.log(`✓ firmness set to ${next} (takes effect on the next agent edit — no restart needed).`);
1558
+ });
1559
+ // ---- provider (per-user synthesis subscription choice) -------------------
1560
+ program
1561
+ .command("provider")
1562
+ .description("Show or set the local coding-assistant subscription Hunch may use for synthesis. Never changes team config.")
1563
+ .argument("[name]", `auto | ${SYNTH_PREFERENCES.filter((p) => p !== "auto").join(" | ")} (omit to inspect)`)
1564
+ .action(async (value) => {
1565
+ const root = findRoot();
1566
+ if (value != null) {
1567
+ const preference = value.trim();
1568
+ if (!SYNTH_PREFERENCES.includes(preference)) {
1569
+ return fail(`provider must be one of: ${SYNTH_PREFERENCES.join(", ")}`);
1570
+ }
1571
+ try {
1572
+ writeSynthesisPreference(root, preference);
1573
+ }
1574
+ catch (error) {
1575
+ return fail(error instanceof Error ? error.message : String(error));
1576
+ }
1577
+ console.log(`✓ local synthesis preference set to ${preference} (gitignored; it never changes a teammate's billing choice).`);
1578
+ }
1579
+ const resolution = await resolveSynthesisProvider({ root });
1580
+ const envValue = process.env.HUNCH_SYNTH_PROVIDER?.trim();
1581
+ const local = readSynthesisPreference(root);
1582
+ const hasValidEnv = !!envValue && SYNTH_PREFERENCES.includes(envValue);
1583
+ console.log(`selected: ${resolution.provider.name} (${resolution.source})`);
1584
+ console.log(`preference: ${hasValidEnv ? `environment: ${envValue}` : `local: ${local}`}`);
1585
+ if (envValue && !hasValidEnv)
1586
+ console.log(dim(`HUNCH_SYNTH_PROVIDER=${envValue} is unknown and is being ignored.`));
1587
+ console.log("available:");
1588
+ for (const status of resolution.statuses) {
1589
+ const billing = status.subscription ? ` — ${status.subscription}` : "";
1590
+ console.log(` ${status.available ? "✓" : "·"} ${status.name}: ${status.label}${billing}`);
1591
+ }
1592
+ if (resolution.source === "ambiguous") {
1593
+ const choices = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => `hunch provider ${s.name}`);
1594
+ console.log(dim("Multiple subscription CLIs are available, so Hunch uses the free deterministic fallback rather than guessing which plan to spend."));
1595
+ console.log(`choose one: ${choices.join(" or ")}`);
1596
+ }
1597
+ else if (resolution.source === "unavailable-preference") {
1598
+ console.log(dim(`Your ${resolution.preference} preference is not available; Hunch is using the local deterministic fallback.`));
1599
+ }
1544
1600
  });
1545
1601
  // ---- status (enforcement readiness at a glance) ---------------------------
1546
1602
  program
@@ -1581,16 +1637,22 @@ program
1581
1637
  console.log("");
1582
1638
  store.close();
1583
1639
  });
1584
- // ---- hook (Claude Code agent-hook handler) --------------------------------
1640
+ // ---- hook (multi-agent lifecycle hook handler) ----------------------------
1585
1641
  program
1586
1642
  .command("hook")
1587
- .description("Claude Code hook handler: inject relevant Hunch context before edits (and, at strict firmness, deny edits that hit a blocking invariant). Reads the hook event JSON on stdin.")
1588
- .action(async () => {
1643
+ .description("Agent-agnostic hook handler: normalizes Claude, VS Code, Cursor, Windsurf, and Antigravity events into Hunch context and strict policy checks. Reads hook JSON on stdin.")
1644
+ .option("--provider <provider>", "hook event dialect: claude | vscode | cursor | windsurf | antigravity", "claude")
1645
+ .action(async (opts) => {
1589
1646
  // A hook MUST NEVER break the agent: on ANY error or unrecognized input we
1590
1647
  // emit nothing and exit 0 (the action defers to Claude Code's normal flow).
1591
1648
  let store = null;
1592
1649
  try {
1593
- const evt = JSON.parse(await readStdin());
1650
+ const provider = hookProvider(opts.provider);
1651
+ if (!provider)
1652
+ return;
1653
+ const evt = normalizeHookEvent(JSON.parse(await readStdin()), provider);
1654
+ if (!evt)
1655
+ return;
1594
1656
  const root = findRoot();
1595
1657
  const paths = hunchPaths(root);
1596
1658
  const firmness = readConfig(paths).firmness;
@@ -1620,7 +1682,7 @@ program
1620
1682
  const verdict = stopVerdict(st, firmness);
1621
1683
  if (verdict.block) {
1622
1684
  savePipelineState(evt.session_id, verdict.state);
1623
- process.stdout.write(JSON.stringify({ decision: "block", reason: verdict.reason }));
1685
+ emitStop(provider, verdict.reason);
1624
1686
  }
1625
1687
  return;
1626
1688
  }
@@ -1642,7 +1704,7 @@ program
1642
1704
  // different content, so it always comes through (dec_244397d920).
1643
1705
  if (injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
1644
1706
  return;
1645
- emitContext("UserPromptSubmit", text);
1707
+ emitContext(provider, "UserPromptSubmit", text);
1646
1708
  return;
1647
1709
  }
1648
1710
  if (evt.hook_event_name === "SessionStart") {
@@ -1658,7 +1720,7 @@ program
1658
1720
  if (!decisions.length) {
1659
1721
  // Fresh graph: nothing to orient on, but the operating loop still ships.
1660
1722
  if (pipelineEnabled())
1661
- emitContext("SessionStart", PIPELINE_LOOP);
1723
+ emitContext(provider, "SessionStart", PIPELINE_LOOP);
1662
1724
  return;
1663
1725
  }
1664
1726
  const L = [];
@@ -1678,7 +1740,13 @@ program
1678
1740
  // (the zod bench showed ambient skills are read in ~0% of sessions).
1679
1741
  if (pipelineEnabled())
1680
1742
  L.push("", PIPELINE_LOOP);
1681
- emitContext("SessionStart", L.join("\n"));
1743
+ const orientation = L.join("\n");
1744
+ // Antigravity's nearest equivalent is PreInvocation, which can fire
1745
+ // repeatedly in one conversation. Deduplicate it just like edit
1746
+ // grounding so it remains an orientation, not a context flood.
1747
+ if (provider === "antigravity" && injectionMode(evt.session_id, "orientation", orientation) === "delta")
1748
+ return;
1749
+ emitContext(provider, "SessionStart", orientation);
1682
1750
  }
1683
1751
  finally {
1684
1752
  s.close();
@@ -1710,7 +1778,7 @@ program
1710
1778
  const proposedLines = proposedEditLines(evt.tool_input);
1711
1779
  const deny = blockingInScope(store, target, proposedLines);
1712
1780
  if (deny) {
1713
- emitDeny(deny.reason);
1781
+ emitDeny(provider, deny.reason);
1714
1782
  return;
1715
1783
  }
1716
1784
  // Veto Guard (live): the proposed edit text re-introduces an approach an
@@ -1718,7 +1786,7 @@ program
1718
1786
  // only human-confirmed tripwires deny.
1719
1787
  const vetoDeny = proposedLines.length ? vetoInScope(store, target, proposedLines) : null;
1720
1788
  if (vetoDeny) {
1721
- emitDeny(vetoDeny.reason);
1789
+ emitDeny(provider, vetoDeny.reason);
1722
1790
  return;
1723
1791
  }
1724
1792
  }
@@ -1761,10 +1829,10 @@ program
1761
1829
  // the full 10-16KB block. Any record change re-sends the full text; the
1762
1830
  // strict-gate deny path above never routes through this (dec_244397d920).
1763
1831
  if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
1764
- emitContext("PreToolUse", `Hunch grounding for ${target}: unchanged this session (${ctx.decisions.length} decision(s), ${ctx.constraints.length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
1832
+ emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${ctx.decisions.length} decision(s), ${ctx.constraints.length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
1765
1833
  return;
1766
1834
  }
1767
- emitContext("PreToolUse", text);
1835
+ emitContext(provider, "PreToolUse", text);
1768
1836
  }
1769
1837
  catch {
1770
1838
  // swallow — never block an edit on a hook failure
@@ -1952,7 +2020,7 @@ program
1952
2020
  // and any per-draft failure degrades to "not judged" (kept for a human).
1953
2021
  const verdicts = new Map();
1954
2022
  if (opts.llm !== false && !opts.private) {
1955
- const provider = await selectProvider();
2023
+ const provider = await selectProvider({ root });
1956
2024
  if (provider.judgeDraft) {
1957
2025
  // The candidate pool for duplicate_of / restatement: the LIVE, vouched records.
1958
2026
  const existing = all
@@ -2271,7 +2339,7 @@ program
2271
2339
  let prose;
2272
2340
  let adoptionProse;
2273
2341
  if (opts.llm !== false) {
2274
- const provider = await selectProvider();
2342
+ const provider = await selectProvider({ root });
2275
2343
  if (provider.draftProse) {
2276
2344
  console.log(`Prose via ${provider.name} (subscription); the drift-bearing skeleton stays deterministic.`);
2277
2345
  prose = (pack, excerpts) => provider.draftProse(wikiPrompt(pack, excerpts));
@@ -2503,24 +2571,24 @@ program
2503
2571
  const onDisk = readManifest(hunchPaths(root)).schema_version;
2504
2572
  const schemaNote = onDisk === SCHEMA_VERSION ? "" : onDisk > SCHEMA_VERSION ? ` ⚠ newer than this Hunch (v${SCHEMA_VERSION}) — upgrade hunch` : ` ⚠ run \`hunch migrate\``;
2505
2573
  console.log(`schema: v${onDisk} (hunch v${SCHEMA_VERSION})${schemaNote}`);
2506
- const provider = await selectProvider();
2507
- console.log(`synthesis: ${provider.name}`);
2508
- // Synthesis is billed to the user's SUBSCRIPTION via a coding-assistant CLI,
2509
- // never a pay-per-token API key. Surface which one — or what's missing.
2510
- const SUB = {
2511
- "claude-cli": { label: "Claude subscription (claude CLI)", strip: "ANTHROPIC_API_KEY" },
2512
- "codex-cli": { label: "ChatGPT subscription (codex CLI)", strip: "OPENAI_API_KEY" },
2513
- "cursor-agent": { label: "Cursor subscription (cursor-agent CLI)" },
2514
- };
2515
- const sub = SUB[provider.name];
2516
- if (sub) {
2517
- const hadKey = sub.strip && !!process.env[sub.strip];
2518
- console.log(` ↳ LLM synthesis billed to your ${sub.label}` +
2519
- (hadKey ? ` (${sub.strip} in env is stripped never billed to the API)` : ``));
2574
+ const resolution = await resolveSynthesisProvider({ root });
2575
+ const provider = resolution.provider;
2576
+ console.log(`synthesis: ${provider.name} (${resolution.source})`);
2577
+ const selected = resolution.statuses.find((s) => s.name === provider.name);
2578
+ if (selected?.subscription) {
2579
+ console.log(` ↳ LLM synthesis uses your ${selected.subscription}; provider API credentials are not used.`);
2580
+ }
2581
+ else if (resolution.source === "ambiguous") {
2582
+ const names = resolution.statuses.filter((s) => s.name !== "deterministic" && s.available).map((s) => s.name);
2583
+ console.log(dim(` ↳ ${names.join(", ")} are available; Hunch will not guess which subscription to spend.`));
2584
+ console.log(dim(` choose one locally: ${names.map((name) => `hunch provider ${name}`).join(" or ")}`));
2585
+ }
2586
+ else if (resolution.source === "unavailable-preference") {
2587
+ console.log(dim(` ${resolution.preference} was selected but is unavailable; using the offline heuristic.`));
2520
2588
  }
2521
2589
  else {
2522
- console.log(dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence)`));
2523
- console.log(dim(` for full synthesis install one: Claude Code (\`claude /login\`), Codex (\`codex login\`), or Cursor (\`cursor-agent login\`)`));
2590
+ console.log(dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence).`));
2591
+ console.log(dim(` install or log into Claude Code, Codex, or Cursor; then select one with \`hunch provider <name>\`.`));
2524
2592
  }
2525
2593
  const c = store.reindex().counts;
2526
2594
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
@@ -2657,13 +2725,22 @@ function realpathNorm(p) {
2657
2725
  function toRepoRel(root, abs) {
2658
2726
  return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
2659
2727
  }
2660
- function emitContext(event, text) {
2661
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }));
2728
+ function emitContext(provider, event, text) {
2729
+ const output = contextHookOutput(provider, event, text);
2730
+ if (output)
2731
+ process.stdout.write(JSON.stringify(output));
2662
2732
  }
2663
- function emitDeny(reason) {
2664
- process.stdout.write(JSON.stringify({
2665
- hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
2666
- }));
2733
+ function emitDeny(provider, reason) {
2734
+ const result = denyHookOutput(provider, reason);
2735
+ if (result.output)
2736
+ process.stdout.write(JSON.stringify(result.output));
2737
+ if (result.stderr)
2738
+ process.stderr.write(`${result.stderr}\n`);
2739
+ if (result.exitCode !== undefined)
2740
+ process.exitCode = result.exitCode;
2741
+ }
2742
+ function emitStop(provider, reason) {
2743
+ process.stdout.write(JSON.stringify(stopHookOutput(provider, reason)));
2667
2744
  }
2668
2745
  program.parseAsync().catch((e) => {
2669
2746
  try {
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Provider hook dialects → Hunch's one internal event shape.
3
+ *
4
+ * Hook payloads are an integration boundary: every provider is free to rename
5
+ * fields or tools. Keep that variability here so the policy engine receives
6
+ * the same small, fail-open shape regardless of the assistant that emitted it.
7
+ */
8
+ export const HOOK_PROVIDERS = ["claude", "vscode", "windsurf", "antigravity", "cursor"];
9
+ function obj(value) {
10
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
11
+ }
12
+ function str(value) {
13
+ return typeof value === "string" && value.trim() ? value : undefined;
14
+ }
15
+ function stringAt(input, ...keys) {
16
+ for (const key of keys) {
17
+ const value = str(input[key]);
18
+ if (value)
19
+ return value;
20
+ }
21
+ return undefined;
22
+ }
23
+ function hunchToolName(name, input) {
24
+ if (!name)
25
+ return undefined;
26
+ const lower = name.toLowerCase();
27
+ if (/multi.*(edit|replace)|edit.*files|multi_replace/.test(lower))
28
+ return "MultiEdit";
29
+ if (/^(edit|strreplace|replace_string_in_file|replace_file_content)$/.test(lower) || /replace.*(string|content)/.test(lower))
30
+ return "Edit";
31
+ if (/^(write|create|create_file|write_to_file)$/.test(lower) || /write.*file/.test(lower))
32
+ return "Write";
33
+ if (/(shell|bash|terminal|run_command|run.*command|powershell)/.test(lower))
34
+ return "Bash";
35
+ if (/skill/.test(lower))
36
+ return "Skill";
37
+ // A provider can call an edit tool something new. A file path plus proposed
38
+ // content is enough to safely treat it as a write for policy purposes.
39
+ if (input.file_path && (input.new_string || input.content || input.edits?.length))
40
+ return "Edit";
41
+ return name;
42
+ }
43
+ function edits(value) {
44
+ if (!Array.isArray(value))
45
+ return undefined;
46
+ const normalized = value
47
+ .map((item) => obj(item))
48
+ .filter((item) => !!item)
49
+ .map((item) => ({ new_string: stringAt(item, "new_string", "newString", "ReplacementContent", "replacementContent") }));
50
+ return normalized.length ? normalized : undefined;
51
+ }
52
+ function normalizeToolInput(value) {
53
+ const raw = obj(value);
54
+ if (!raw)
55
+ return undefined;
56
+ const replacementChunks = Array.isArray(raw.ReplacementChunks) ? raw.ReplacementChunks : raw.replacementChunks;
57
+ const chunkEdits = Array.isArray(replacementChunks)
58
+ ? replacementChunks.map((chunk) => obj(chunk)).filter((chunk) => !!chunk)
59
+ .map((chunk) => ({ new_string: stringAt(chunk, "ReplacementContent", "replacementContent", "new_string", "newString") }))
60
+ : undefined;
61
+ const out = {
62
+ file_path: stringAt(raw, "file_path", "filePath", "path", "uri", "TargetFile", "targetFile", "AbsolutePath", "absolutePath"),
63
+ new_string: stringAt(raw, "new_string", "newString", "ReplacementContent", "replacementContent", "TargetContent", "targetContent"),
64
+ content: stringAt(raw, "content", "contents", "CodeContent", "codeContent"),
65
+ edits: edits(raw.edits) ?? edits(raw.files) ?? chunkEdits,
66
+ command: stringAt(raw, "command", "commandLine", "CommandLine", "cmd"),
67
+ skill: stringAt(raw, "skill", "skillName", "name"),
68
+ };
69
+ return Object.values(out).some((v) => v !== undefined) ? out : undefined;
70
+ }
71
+ function eventName(value, provider) {
72
+ if (typeof value !== "string")
73
+ return undefined;
74
+ const name = value.toLowerCase();
75
+ const map = {
76
+ pretooluse: "PreToolUse",
77
+ posttooluse: "PostToolUse",
78
+ userpromptsubmit: "UserPromptSubmit",
79
+ sessionstart: "SessionStart",
80
+ stop: "Stop",
81
+ };
82
+ if (map[name])
83
+ return map[name];
84
+ if (provider === "cursor") {
85
+ if (name === "beforesubmitprompt")
86
+ return "UserPromptSubmit";
87
+ if (name === "beforetoolexecution" || name === "beforefileedit" || name === "beforeshellexecution")
88
+ return "PreToolUse";
89
+ if (name === "afterfileedit" || name === "aftershellexecution")
90
+ return "PostToolUse";
91
+ }
92
+ if (provider === "windsurf") {
93
+ if (name === "pre_write_code" || name === "pre_run_command")
94
+ return "PreToolUse";
95
+ if (name === "post_write_code" || name === "post_run_command")
96
+ return "PostToolUse";
97
+ if (name === "pre_user_prompt")
98
+ return "UserPromptSubmit";
99
+ }
100
+ // Antigravity's PreInvocation is the lifecycle point which can inject a
101
+ // transient message before the model sees the turn. Internally it provides
102
+ // Hunch's session-orientation behavior.
103
+ if (provider === "antigravity" && name === "preinvocation")
104
+ return "SessionStart";
105
+ return undefined;
106
+ }
107
+ /** Parse a provider name supplied by a hook config. Unknown values intentionally
108
+ * return null so a bad config cannot make an edit fail. */
109
+ export function hookProvider(value) {
110
+ return typeof value === "string" && HOOK_PROVIDERS.includes(value.toLowerCase())
111
+ ? value.toLowerCase()
112
+ : null;
113
+ }
114
+ /** Normalize a hook stdin payload. Unknown/malformed events return null and the
115
+ * CLI exits successfully without output — the Never Block on Hook Failure rule. */
116
+ export function normalizeHookEvent(raw, provider) {
117
+ const input = obj(raw);
118
+ if (!input)
119
+ return null;
120
+ if (provider === "antigravity") {
121
+ const agEvent = input.toolCall ? "PreToolUse" : input.invocationNum !== undefined ? "PreInvocation" : input.executionNum !== undefined ? "Stop" : undefined;
122
+ const event = eventName(agEvent, provider);
123
+ if (!event)
124
+ return null;
125
+ const call = obj(input.toolCall);
126
+ const toolInput = normalizeToolInput(call?.args);
127
+ return {
128
+ hook_event_name: event,
129
+ session_id: stringAt(input, "conversationId"),
130
+ tool_name: hunchToolName(stringAt(call ?? {}, "name"), toolInput ?? {}),
131
+ tool_input: toolInput,
132
+ };
133
+ }
134
+ if (provider === "windsurf") {
135
+ const event = eventName(input.event ?? input.hook_event_name, provider);
136
+ if (!event)
137
+ return null;
138
+ const info = obj(input.tool_info) ?? obj(input.toolInput) ?? obj(input.tool_input);
139
+ const toolInput = normalizeToolInput(info);
140
+ return {
141
+ hook_event_name: event,
142
+ session_id: stringAt(input, "trajectory_id", "session_id", "sessionId"),
143
+ tool_name: hunchToolName(stringAt(input, "agent_action_name", "tool_name", "toolName"), toolInput ?? {}),
144
+ tool_input: toolInput,
145
+ prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
146
+ };
147
+ }
148
+ const event = eventName(input.hook_event_name ?? input.hookEventName ?? input.event, provider);
149
+ if (!event)
150
+ return null;
151
+ const toolInput = normalizeToolInput(input.tool_input ?? input.toolInput);
152
+ return {
153
+ hook_event_name: event,
154
+ session_id: stringAt(input, "session_id", "sessionId", "conversation_id", "conversationId"),
155
+ tool_name: hunchToolName(stringAt(input, "tool_name", "toolName"), toolInput ?? {}),
156
+ tool_input: toolInput,
157
+ prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
158
+ };
159
+ }
160
+ /** Provider-aware hook output. Context output is intentionally omitted for
161
+ * Windsurf because its documented hook protocol has no agent-context channel;
162
+ * its always-on project rule + MCP server remain the grounding delivery path. */
163
+ export function contextHookOutput(provider, event, text) {
164
+ if (provider === "windsurf")
165
+ return null;
166
+ if (provider === "antigravity") {
167
+ return event === "SessionStart" ? { injectSteps: [{ ephemeralMessage: text }] } : { decision: "allow" };
168
+ }
169
+ if (provider === "cursor")
170
+ return { permission: "allow", agent_message: text };
171
+ return { hookSpecificOutput: { hookEventName: event, additionalContext: text } };
172
+ }
173
+ /** Strict-deny response in each native dialect. Windsurf uses documented exit
174
+ * code 2; the caller writes this error to stderr and preserves exit success for
175
+ * every accidental/malformed invocation. */
176
+ export function denyHookOutput(provider, reason) {
177
+ if (provider === "windsurf")
178
+ return { output: null, exitCode: 2, stderr: reason };
179
+ if (provider === "antigravity")
180
+ return { output: { decision: "deny", reason } };
181
+ if (provider === "cursor")
182
+ return { output: { permission: "deny", user_message: reason, agent_message: reason } };
183
+ return {
184
+ output: { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } },
185
+ };
186
+ }
187
+ /** Stop-gate output in each native dialect. */
188
+ export function stopHookOutput(provider, reason) {
189
+ if (provider === "vscode")
190
+ return { continue: false, stopReason: reason };
191
+ if (provider === "cursor")
192
+ return { followup_message: reason };
193
+ if (provider === "antigravity")
194
+ return { decision: "continue", reason };
195
+ return { decision: "block", reason };
196
+ }
197
+ //# sourceMappingURL=agenthook.js.map
@@ -1,7 +1,7 @@
1
1
  /** Hunch user config (`.hunch/config.json`) — runtime knobs that are NOT schema
2
2
  * state (the on-disk schema version lives in manifest.json). Committed alongside
3
3
  * the graph, so a whole team shares the same settings — e.g. how firmly the
4
- * Claude Code agent hook enforces engineering memory before an edit. */
4
+ * agent lifecycle hooks enforce engineering memory before an edit. */
5
5
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
7
  export const FIRMNESS_LEVELS = ["off", "advisory", "firm", "strict"];
@@ -4,7 +4,7 @@
4
4
  * 20+ times per session buries the agent's working context under repeats — the
5
5
  * cost of being grounded starts competing with the work.
6
6
  *
7
- * Mechanism: per Claude-Code session (the hook event carries session_id), keep
7
+ * Mechanism: per agent session (the hook event carries a provider-normalized session_id), keep
8
8
  * a tiny {key → content-hash} map in the OS tmpdir. First injection for a key
9
9
  * (or any time the underlying records CHANGE) → "full". Identical repeat →
10
10
  * "delta" (the caller emits a one-liner, or nothing).
@@ -137,6 +137,32 @@ function writeJson(file, obj) {
137
137
  writeFileSync(file, JSON.stringify(obj, null, 2) + "\n");
138
138
  return file;
139
139
  }
140
+ /** Provider hook commands live in tracked config files, so use the structured
141
+ * invocation (the same portable npx package reference as MCP) rather than a
142
+ * machine-local CLI path. JSON quoting is accepted by POSIX shells and keeps
143
+ * paths with spaces intact for source/dev installs. */
144
+ function hookCommand(inv, provider) {
145
+ return [...[inv.command], ...inv.args, "hook", "--provider", provider].map((part) => JSON.stringify(part)).join(" ");
146
+ }
147
+ function isHunchProviderHook(entry) {
148
+ const e = entry && typeof entry === "object" ? entry : null;
149
+ const command = typeof e?.command === "string" ? e.command : "";
150
+ return /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts))/.test(command) && /\bhook\b/.test(command);
151
+ }
152
+ /** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
153
+ * We replace only old Hunch commands and leave every foreign hook in place. */
154
+ function writeHookConfig(file, entries) {
155
+ const json = readJsonObj(file);
156
+ const hooks = json.hooks && typeof json.hooks === "object" && !Array.isArray(json.hooks)
157
+ ? json.hooks
158
+ : {};
159
+ for (const [event, next] of Object.entries(entries)) {
160
+ const old = Array.isArray(hooks[event]) ? hooks[event] : [];
161
+ hooks[event] = [...old.filter((entry) => !isHunchProviderHook(entry)), ...next];
162
+ }
163
+ json.hooks = hooks;
164
+ return writeJson(file, json);
165
+ }
140
166
  /** Cursor: .cursor/mcp.json — same `mcpServers` shape as Claude Desktop/Code. */
141
167
  export function writeCursorMcp(root, inv) {
142
168
  const file = join(root, ".cursor", "mcp.json");
@@ -154,15 +180,15 @@ export function writeVscodeMcp(root, inv) {
154
180
  json.servers.hunch = { type: "stdio", command: inv.command, args: [...inv.args, "mcp"] };
155
181
  return writeJson(file, json);
156
182
  }
157
- /** Google Antigravity's MCP config is GLOBAL (user home), not project-local — and the
158
- * dir moved between versions (`antigravity/` vs `config/`). Resolve adaptively: an
159
- * existing config wins, else an existing parent dir, else null (Antigravity not
160
- * installed we never create a global config for an absent tool). `home` is injectable
161
- * for tests so we never touch the real ~/.gemini. */
183
+ /** Google Antigravity's global MCP config moved between releases. Resolve
184
+ * adaptively: an existing config wins, else an existing parent dir, else null
185
+ * (Antigravity not installed we never create a global config for an absent
186
+ * tool). The current project-local config is handled separately below. `home`
187
+ * is injectable for tests so we never touch the real ~/.gemini. */
162
188
  export function antigravityMcpFile(home = homedir()) {
163
189
  const candidates = [
164
- join(home, ".gemini", "antigravity", "mcp_config.json"),
165
190
  join(home, ".gemini", "config", "mcp_config.json"),
191
+ join(home, ".gemini", "antigravity", "mcp_config.json"), // legacy
166
192
  ];
167
193
  for (const c of candidates)
168
194
  if (existsSync(c))
@@ -185,6 +211,16 @@ export function writeAntigravityMcp(inv, home = homedir()) {
185
211
  json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
186
212
  return writeJson(file, json);
187
213
  }
214
+ /** Current Antigravity IDE/CLI project config. Unlike a global config this is
215
+ * committed with the repository, so every clone gets the same private/local
216
+ * Hunch server without touching a user's home directory. */
217
+ export function writeAntigravityWorkspaceMcp(root, inv) {
218
+ const file = join(root, ".agents", "mcp_config.json");
219
+ const json = readJsonObj(file);
220
+ json.mcpServers = json.mcpServers ?? {};
221
+ json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
222
+ return writeJson(file, json);
223
+ }
188
224
  const TOML_START = "# >>> hunch mcp (managed) >>>";
189
225
  const TOML_END = "# <<< hunch mcp <<<";
190
226
  /** Codex CLI: .codex/config.toml — `[mcp_servers.hunch]` stdio entry. We own only
@@ -249,6 +285,22 @@ export function writeWindsurfMcp(root, inv) {
249
285
  json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
250
286
  return writeJson(file, json);
251
287
  }
288
+ /** Current Windsurf also discovers a user config at ~/.codeium/windsurf. Only
289
+ * touch it when the tool is already installed/configured; Hunch never creates a
290
+ * global configuration for an application the user does not have. */
291
+ export function windsurfMcpFile(home = homedir()) {
292
+ const file = join(home, ".codeium", "windsurf", "mcp_config.json");
293
+ return existsSync(file) || existsSync(dirname(file)) ? file : null;
294
+ }
295
+ export function writeWindsurfGlobalMcp(inv, home = homedir()) {
296
+ const file = windsurfMcpFile(home);
297
+ if (!file)
298
+ return null;
299
+ const json = readJsonObj(file);
300
+ json.mcpServers = json.mcpServers ?? {};
301
+ json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
302
+ return writeJson(file, json);
303
+ }
252
304
  /** Windsurf project rule (.windsurf/rules/hunch.md). `trigger: always_on` keeps the
253
305
  * Hunch grounding in Cascade's context for every request. Fully managed (overwritten). */
254
306
  export function writeWindsurfRule(root, store) {
@@ -258,6 +310,86 @@ export function writeWindsurfRule(root, store) {
258
310
  writeFileSync(file, body);
259
311
  return file;
260
312
  }
313
+ /** Cursor's hook API is beta, but its project-level config accepts this standard
314
+ * event map. Context delivery is opportunistic; the always-on rule and MCP
315
+ * registration remain the durable grounding path if a Cursor build suppresses
316
+ * a hook's agent_message. */
317
+ export function writeCursorHooks(root, inv) {
318
+ const file = join(root, ".cursor", "hooks.json");
319
+ const command = hookCommand(inv, "cursor");
320
+ const written = writeHookConfig(file, {
321
+ sessionStart: [{ command }],
322
+ beforeSubmitPrompt: [{ command }],
323
+ preToolUse: [{ command }],
324
+ postToolUse: [{ command }],
325
+ stop: [{ command }],
326
+ });
327
+ const json = readJsonObj(written);
328
+ if (json.version === undefined) {
329
+ json.version = 1;
330
+ writeJson(written, json);
331
+ }
332
+ return written;
333
+ }
334
+ /** VS Code's native workspace hook location. It supports all lifecycle events
335
+ * Hunch needs and uses the same stdout contract as Claude Code, with different
336
+ * camelCase tool fields normalized in core/agenthook.ts. */
337
+ export function writeVscodeHooks(root, inv) {
338
+ const file = join(root, ".github", "hooks", "hunch.json");
339
+ const command = hookCommand(inv, "vscode");
340
+ return writeHookConfig(file, {
341
+ SessionStart: [{ type: "command", command }],
342
+ UserPromptSubmit: [{ type: "command", command }],
343
+ PreToolUse: [{ type: "command", command }],
344
+ PostToolUse: [{ type: "command", command }],
345
+ Stop: [{ type: "command", command }],
346
+ });
347
+ }
348
+ /** Windsurf's documented workspace hooks. It only supports deterministic
349
+ * pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
350
+ * reserves the hook for strict edit protection and pipeline observation. */
351
+ export function writeWindsurfHooks(root, inv) {
352
+ const file = join(root, ".windsurf", "hooks.json");
353
+ const command = hookCommand(inv, "windsurf");
354
+ return writeHookConfig(file, {
355
+ pre_user_prompt: [{ command, show_output: false }],
356
+ pre_write_code: [{ command, show_output: false }],
357
+ post_write_code: [{ command, show_output: false }],
358
+ post_run_command: [{ command, show_output: false }],
359
+ });
360
+ }
361
+ function antigravityHandler(command) {
362
+ return { type: "command", command, timeout: 15 };
363
+ }
364
+ /** Antigravity keeps hook groups at the top level (not under `hooks`). Hunch
365
+ * owns only the `hunch` group and replaces its own old entries idempotently. */
366
+ export function writeAntigravityHooks(root, inv) {
367
+ const file = join(root, ".agents", "hooks.json");
368
+ const json = readJsonObj(file);
369
+ const group = json.hunch && typeof json.hunch === "object" && !Array.isArray(json.hunch)
370
+ ? json.hunch
371
+ : {};
372
+ const command = hookCommand(inv, "antigravity");
373
+ const keep = (event) => Array.isArray(group[event])
374
+ ? group[event].filter((entry) => {
375
+ const e = entry && typeof entry === "object" ? entry : null;
376
+ if (isHunchProviderHook(e))
377
+ return false;
378
+ return !Array.isArray(e?.hooks) || !e.hooks.some((hook) => isHunchProviderHook(hook));
379
+ })
380
+ : [];
381
+ group.PreInvocation = [...keep("PreInvocation"), antigravityHandler(command)];
382
+ group.PreToolUse = [
383
+ ...keep("PreToolUse"),
384
+ {
385
+ matcher: "write_to_file|replace_file_content|multi_replace_file_content",
386
+ hooks: [antigravityHandler(command)],
387
+ },
388
+ ];
389
+ group.Stop = [...keep("Stop"), antigravityHandler(command)];
390
+ json.hunch = group;
391
+ return writeJson(file, json);
392
+ }
261
393
  /** Rewrite the auto-maintained Hunch section in EVERY assistant grounding doc
262
394
  * (CLAUDE.md, AGENTS.md, Copilot instructions, Cursor + Windsurf rules) from the
263
395
  * current store — without touching the MCP/provider config files. `hunch private
@@ -300,28 +432,50 @@ export function refreshExistingGrounding(root, store) {
300
432
  }
301
433
  return changed;
302
434
  }
435
+ /** A malformed configuration for one surface (for example an MCP file) must not
436
+ * prevent the same assistant's rule or lifecycle hook from being installed. */
437
+ function runProvider(writers) {
438
+ const files = [];
439
+ const errors = [];
440
+ for (const write of writers) {
441
+ try {
442
+ const result = write();
443
+ files.push(...(Array.isArray(result) ? result : [result]));
444
+ }
445
+ catch (e) {
446
+ errors.push(e.message);
447
+ }
448
+ }
449
+ return { files, ...(errors.length ? { error: errors.join("; ") } : {}) };
450
+ }
303
451
  /** Scaffold MCP config + grounding for all supported assistants. Returns a
304
452
  * per-assistant summary for `hunch init` to print. Each assistant is isolated:
305
453
  * a writer that refuses to clobber a malformed file degrades to a warning rather
306
454
  * than aborting the rest. Claude Code is handled separately by scaffold.ts. */
307
- export function scaffoldProviders(root, inv, store) {
455
+ export function scaffoldProviders(root, inv, store, options = {}) {
456
+ const hooks = options.agentHooks !== false;
457
+ const home = options.home;
308
458
  const tasks = [
309
- ["Cursor", () => [writeCursorMcp(root, inv), writeCursorRule(root, store)]],
310
- ["VS Code (Copilot)", () => [writeVscodeMcp(root, inv), writeCopilotInstructions(root, store)]],
311
- ["Codex CLI", () => [writeCodexConfig(root, inv)]],
312
- ["Windsurf", () => [writeWindsurfMcp(root, inv), writeWindsurfRule(root, store)]],
313
- // Antigravity reads project-root AGENTS.md for grounding (written below); its MCP
314
- // config is global + detection-gated, so it only writes when Antigravity is installed.
315
- ["Google Antigravity", () => { const f = writeAntigravityMcp(inv); return f ? [f] : []; }],
316
- ["Any (AGENTS.md)", () => [writeAgentsMd(root, store)]],
459
+ ["Cursor", () => runProvider([() => writeCursorMcp(root, inv), () => writeCursorRule(root, store), ...(hooks ? [() => writeCursorHooks(root, inv)] : [])])],
460
+ ["VS Code (Copilot)", () => runProvider([() => writeVscodeMcp(root, inv), () => writeCopilotInstructions(root, store), ...(hooks ? [() => writeVscodeHooks(root, inv)] : [])])],
461
+ ["Codex CLI", () => runProvider([() => writeCodexConfig(root, inv)])],
462
+ ["Windsurf", () => {
463
+ return runProvider([
464
+ () => writeWindsurfMcp(root, inv),
465
+ () => writeWindsurfRule(root, store),
466
+ ...(hooks ? [() => writeWindsurfHooks(root, inv)] : []),
467
+ () => { const global = writeWindsurfGlobalMcp(inv, home); return global ?? []; },
468
+ ]);
469
+ }],
470
+ ["Google Antigravity", () => {
471
+ return runProvider([
472
+ () => writeAntigravityWorkspaceMcp(root, inv),
473
+ ...(hooks ? [() => writeAntigravityHooks(root, inv)] : []),
474
+ () => { const global = writeAntigravityMcp(inv, home); return global ?? []; },
475
+ ]);
476
+ }],
477
+ ["Any (AGENTS.md)", () => runProvider([() => writeAgentsMd(root, store)])],
317
478
  ];
318
- return tasks.map(([assistant, run]) => {
319
- try {
320
- return { assistant, files: run() };
321
- }
322
- catch (e) {
323
- return { assistant, files: [], error: e.message };
324
- }
325
- });
479
+ return tasks.map(([assistant, run]) => ({ assistant, ...run() }));
326
480
  }
327
481
  //# sourceMappingURL=providers.js.map
@@ -1,23 +1,26 @@
1
1
  /**
2
2
  * Pluggable synthesis provider for the WRITE path (DESIGN.md §4 / §7).
3
3
  *
4
- * LLM synthesis is driven by the user's Claude **subscription** via the `claude`
5
- * CLI — never the pay-per-token Anthropic API. We try, in order:
6
- * claude-cli deterministic-fallback. The fallback always works (no creds, no
7
- * network) and emits a LOW-confidence draft, honoring the design rule that
8
- * auto-captured memory is advisory and cheap to discard.
4
+ * LLM synthesis is driven by the user's chosen coding-assistant subscription
5
+ * CLI — never a pay-per-token API. Claude Code, Codex, and Cursor use different
6
+ * auth surfaces, but every provider returns the same shape. When more than one
7
+ * subscription CLI is available, Hunch deliberately does NOT guess whose plan
8
+ * to spend: the user chooses once with `hunch provider <name>` (stored locally)
9
+ * or overrides per shell with HUNCH_SYNTH_PROVIDER. Ambiguous auto mode stays
10
+ * deterministic and free.
9
11
  *
10
- * Subscription, not API: Claude Code's auth precedence puts `ANTHROPIC_API_KEY`
11
- * (and `ANTHROPIC_AUTH_TOKEN`) ABOVE subscription OAuth, and in headless `-p`
12
- * mode the API key is *always* used when present. So we strip those vars from
13
- * the child env (see ClaudeCliProvider.run) to force the CLI down to subscription
14
- * OAuth / CLAUDE_CODE_OAUTH_TOKEN. There is intentionally NO API-key provider.
12
+ * Subscription, not API: provider-specific API credentials are removed from the
13
+ * child env wherever the CLI would otherwise prefer them. There is intentionally
14
+ * NO direct API-key provider.
15
15
  *
16
16
  * Every provider returns the same shape so the rest of the system never knows
17
17
  * (or cares) which one ran.
18
18
  */
19
19
  import { spawn } from "node:child_process";
20
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
20
21
  import { tmpdir } from "node:os";
22
+ import { dirname, join } from "node:path";
23
+ import { writeFileAtomic } from "../core/io.js";
21
24
  import { summarizeDiff } from "../extractors/diff.js";
22
25
  const IS_WIN = process.platform === "win32";
23
26
  /**
@@ -96,6 +99,16 @@ export function pexecIn(cmd, args, opts = {}) {
96
99
  child.stdin.end();
97
100
  });
98
101
  }
102
+ /** Every selectable synthesis mode. `auto` is a preference value rather than a
103
+ * provider: it uses a subscription only when exactly one usable CLI is found. */
104
+ export const SYNTH_PROVIDER_NAMES = ["claude-cli", "codex-cli", "cursor-agent", "deterministic"];
105
+ export const SYNTH_PREFERENCES = ["auto", ...SYNTH_PROVIDER_NAMES];
106
+ const PROVIDER_INFO = {
107
+ "claude-cli": { label: "Claude Code", subscription: "Claude subscription" },
108
+ "codex-cli": { label: "Codex", subscription: "ChatGPT subscription" },
109
+ "cursor-agent": { label: "Cursor Agent", subscription: "Cursor subscription" },
110
+ deterministic: { label: "Deterministic local fallback", subscription: null },
111
+ };
99
112
  const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
100
113
  developer activity (a git commit diff, or a test failure) into a single structured
101
114
  "why" record. Be precise and evidence-grounded; never invent facts not supported by
@@ -169,7 +182,7 @@ const VERIFY_TOOL = {
169
182
  // --------------------------------------------------------------------------
170
183
  // Base for headless-CLI SUBSCRIPTION providers. Each one drives a coding-assistant
171
184
  // CLI billed to the user's own subscription (never a pay-per-token API key — see
172
- // dec_5a7c0733f7). The prompt always goes over STDIN (never argv — keeps untrusted
185
+ // dec_65b058de66). The prompt always goes over STDIN (never argv — keeps untrusted
173
186
  // diff content out of any shell pexecIn uses on Windows), and the CLI's text output
174
187
  // is handed to the SAME mappers, so the rest of the system is provider-agnostic.
175
188
  // --------------------------------------------------------------------------
@@ -447,9 +460,9 @@ export function extractCodexText(out) {
447
460
  return agentTexts[agentTexts.length - 1];
448
461
  return texts.length ? texts[texts.length - 1] : out;
449
462
  }
450
- // Priority order: try each subscription CLI, then the always-available heuristic.
451
- // HUNCH_SYNTH_PROVIDER forces one by name (claude-cli / codex-cli / cursor-agent /
452
- // deterministic).
463
+ // This registry is deliberately NOT a priority order. Auto mode only spends a
464
+ // subscription when it can identify exactly one usable CLI; see
465
+ // resolveSynthesisProvider below.
453
466
  const PROVIDERS = [
454
467
  new ClaudeCliProvider(),
455
468
  new CodexCliProvider(),
@@ -457,31 +470,126 @@ const PROVIDERS = [
457
470
  new DeterministicProvider(),
458
471
  ];
459
472
  // Availability rarely changes within a process (a CLI doesn't get installed mid-run),
460
- // and selectProvider() runs on every sync/recordFailure so memoize each probe.
461
- // Especially matters in the long-lived MCP server and on machines with NO assistant
462
- // CLI, where an uncached pass spawns one failing `--version` per provider every time.
463
- const availCache = new Map();
473
+ // and selection runs on every sync/recordFailure. Cache by object identity rather than
474
+ // name so injected test registries never inherit a stale result from another provider.
475
+ const availCache = new WeakMap();
464
476
  function isAvailable(p) {
465
- let v = availCache.get(p.name);
477
+ let v = availCache.get(p);
466
478
  if (!v) {
467
479
  v = p.available().catch(() => false);
468
- availCache.set(p.name, v);
480
+ availCache.set(p, v);
469
481
  }
470
482
  return v;
471
483
  }
472
- /** Choose the first available provider, honoring HUNCH_SYNTH_PROVIDER override. */
473
- export async function selectProvider() {
474
- const forced = process.env.HUNCH_SYNTH_PROVIDER;
475
- if (forced) {
476
- const p = PROVIDERS.find((x) => x.name === forced);
477
- if (p && (await isAvailable(p)))
478
- return p;
484
+ function isSynthPreference(value) {
485
+ return !!value && SYNTH_PREFERENCES.includes(value);
486
+ }
487
+ function fallbackProvider(providers) {
488
+ return providers.find((p) => p.name === "deterministic") ?? new DeterministicProvider();
489
+ }
490
+ function localPreferencePath(root) {
491
+ return join(root, ".hunch", "local.json");
492
+ }
493
+ /** Read a per-user, gitignored choice. Invalid/missing local state is treated as auto;
494
+ * `writeSynthesisPreference` refuses to overwrite malformed data so this forgiveness
495
+ * never destroys someone else's local settings. */
496
+ export function readSynthesisPreference(root) {
497
+ try {
498
+ const file = localPreferencePath(root);
499
+ if (!existsSync(file))
500
+ return "auto";
501
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
502
+ return typeof parsed.synthProvider === "string" && isSynthPreference(parsed.synthProvider)
503
+ ? parsed.synthProvider
504
+ : "auto";
479
505
  }
480
- for (const p of PROVIDERS) {
481
- if (await isAvailable(p))
482
- return p;
506
+ catch {
507
+ return "auto";
508
+ }
509
+ }
510
+ /** Persist the user's provider choice only in `.hunch/local.json`, which is never a
511
+ * repository policy. That means each developer controls their own subscription spend. */
512
+ export function writeSynthesisPreference(root, preference) {
513
+ if (!isSynthPreference(preference))
514
+ throw new Error(`unknown synthesis provider preference: ${preference}`);
515
+ const file = localPreferencePath(root);
516
+ let local = {};
517
+ if (existsSync(file)) {
518
+ const raw = readFileSync(file, "utf8");
519
+ if (raw.trim()) {
520
+ try {
521
+ const parsed = JSON.parse(raw);
522
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object")
523
+ throw new Error("not an object");
524
+ local = parsed;
525
+ }
526
+ catch {
527
+ throw new Error(`refusing to overwrite malformed local configuration: ${file}`);
528
+ }
529
+ }
530
+ }
531
+ mkdirSync(dirname(file), { recursive: true });
532
+ writeFileAtomic(file, `${JSON.stringify({ ...local, synthProvider: preference }, null, 2)}\n`);
533
+ }
534
+ async function statusesFor(providers) {
535
+ const statuses = [];
536
+ for (const provider of providers) {
537
+ if (!SYNTH_PROVIDER_NAMES.includes(provider.name))
538
+ continue;
539
+ const name = provider.name;
540
+ const info = PROVIDER_INFO[name];
541
+ statuses.push({ name, ...info, available: await isAvailable(provider) });
542
+ }
543
+ return statuses;
544
+ }
545
+ /** Resolve the provider without ever inferring which of several installed products is
546
+ * the one the user intends to spend. Precedence is deliberate: a one-shell override,
547
+ * then a per-user local preference, then safe auto-detection. */
548
+ export async function resolveSynthesisProvider(opts = {}) {
549
+ const providers = opts.providers ?? PROVIDERS;
550
+ const env = opts.env ?? process.env;
551
+ const statuses = await statusesFor(providers);
552
+ const fallback = fallbackProvider(providers);
553
+ const find = (name) => providers.find((p) => p.name === name);
554
+ const usable = async (name) => {
555
+ const provider = find(name);
556
+ return provider && await isAvailable(provider) ? provider : undefined;
557
+ };
558
+ const environment = env.HUNCH_SYNTH_PROVIDER?.trim();
559
+ if (environment && isSynthPreference(environment) && environment !== "auto") {
560
+ const selected = await usable(environment);
561
+ if (selected)
562
+ return { provider: selected, source: "environment", preference: environment, statuses };
563
+ return { provider: fallback, source: "unavailable-preference", preference: environment, statuses };
564
+ }
565
+ // `HUNCH_SYNTH_PROVIDER=auto` is useful in CI or a shell profile: it explicitly
566
+ // suppresses the local preference and re-enters the safe auto policy.
567
+ const preference = environment === "auto"
568
+ ? "auto"
569
+ : opts.root ? readSynthesisPreference(opts.root) : "auto";
570
+ if (preference !== "auto") {
571
+ const selected = await usable(preference);
572
+ if (selected)
573
+ return { provider: selected, source: "local", preference, statuses };
574
+ return { provider: fallback, source: "unavailable-preference", preference, statuses };
575
+ }
576
+ const available = statuses.filter((status) => status.name !== "deterministic" && status.available);
577
+ if (available.length === 1) {
578
+ const selected = await usable(available[0].name);
579
+ if (selected)
580
+ return { provider: selected, source: "single-available", preference, statuses };
483
581
  }
484
- return new DeterministicProvider();
582
+ return {
583
+ provider: fallback,
584
+ source: available.length > 1 ? "ambiguous" : "none",
585
+ preference,
586
+ statuses,
587
+ };
588
+ }
589
+ /** The provider used by normal synthesis. See `resolveSynthesisProvider` for a
590
+ * diagnosable result with the selection source and every candidate's availability. */
591
+ export async function selectProvider(opts = {}) {
592
+ return (await resolveSynthesisProvider(opts)).provider;
485
593
  }
486
594
  // ---- Deep Synthesis: ensemble of subscription CLIs ------------------------
487
595
  // Opt-in (backfill/sync --deep): fan a commit out to EVERY available subscription
@@ -490,9 +598,9 @@ export async function selectProvider() {
490
598
  // the guard path; confidence is capped below the strict gate so output stays advisory.
491
599
  /** All available subscription-CLI workers (claude/codex/cursor), excluding the
492
600
  * deterministic fallback — the pool Deep Synthesis fans a commit out to. */
493
- export async function selectWorkers() {
601
+ export async function selectWorkers(opts = {}) {
494
602
  const out = [];
495
- for (const p of PROVIDERS) {
603
+ for (const p of opts.providers ?? PROVIDERS) {
496
604
  if (p.name === "deterministic")
497
605
  continue; // workers are real subscription CLIs only
498
606
  if (await isAvailable(p))
@@ -592,7 +700,7 @@ export class EnsembleProvider {
592
700
  * (the caller then falls back to the normal single-provider path). `samples` sets
593
701
  * the self-consistency depth for the single-CLI case. */
594
702
  export async function selectEnsemble(opts = {}) {
595
- const workers = await selectWorkers();
703
+ const workers = await selectWorkers(opts);
596
704
  // The self-consistency policy default (DEFAULT_SAMPLES) is applied HERE, not in the
597
705
  // provider — so a single CLI under --deep is sampled N times, while direct
598
706
  // construction stays passthrough. `--samples 1` opts back out.
@@ -601,9 +709,9 @@ export async function selectEnsemble(opts = {}) {
601
709
  /** Pick a CLI provider to run the Critic pass (subscription-only, like the workers).
602
710
  * Returns null when no assistant CLI is installed — verification then no-ops and the
603
711
  * un-audited draft stands (graceful degradation; dec_18a81c8291). */
604
- export async function selectVerifier() {
605
- const workers = await selectWorkers();
606
- return workers[0] ?? null;
712
+ export async function selectVerifier(opts = {}) {
713
+ const { provider } = await resolveSynthesisProvider(opts);
714
+ return provider.name === "deterministic" ? null : provider;
607
715
  }
608
716
  // ---- Verification (the Critic pass) ---------------------------------------
609
717
  // Audit a draft against the commit it came from, then PRUNE unsupported
@@ -98,9 +98,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
98
98
  const provider = localOnly
99
99
  ? new DeterministicProvider()
100
100
  : opts.deep
101
- ? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider()
101
+ ? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider({ root })
102
102
  : opts.force || opts.verify || isSignificant(meta, analysis, codeFiles)
103
- ? await selectProvider()
103
+ ? await selectProvider({ root })
104
104
  : new DeterministicProvider();
105
105
  const input = { subject: meta.subject, body: meta.body, files: codeFiles, diff, analysis };
106
106
  let draft = await draftDecisionSafe(provider, input);
@@ -108,7 +108,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
108
108
  // (BEFORE they scaffold tripwires below) and consequences, and lower confidence on weak
109
109
  // grounding. No-ops when no assistant CLI is available; never raises trust (dec_9a2f2fe72a).
110
110
  if (wantVerify)
111
- draft = await verifyDecisionSafe(await selectVerifier(), input, draft);
111
+ draft = await verifyDecisionSafe(await selectVerifier({ root }), input, draft);
112
112
  // Advisory synthesis telemetry for `hunch review` — which provider ran, how many drafts
113
113
  // were reconciled, their agreement, and the verifier's grounding. Rides in `evidence`
114
114
  // (no schema change → respects forward-migration invariant con_947c578b2c).
@@ -199,7 +199,7 @@ export async function recordFailure(store, root, failure, opts = {}) {
199
199
  // A private bug may contain a stack trace, customer data, or secrets. Keep the
200
200
  // whole capture local unless the caller deliberately routes it through a shared
201
201
  // (non-private) workflow.
202
- const provider = opts.private ? new DeterministicProvider() : await selectProvider();
202
+ const provider = opts.private ? new DeterministicProvider() : await selectProvider({ root });
203
203
  const input = {
204
204
  test: failure.test,
205
205
  message: failure.message,
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
- "description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",
6
+ "description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Antigravity, Codex).",
7
7
  "homepage": "https://hunch-pi.vercel.app",
8
8
  "repository": {
9
9
  "type": "git",
@@ -27,6 +27,10 @@
27
27
  },
28
28
  "keywords": [
29
29
  "claude-code",
30
+ "cursor",
31
+ "copilot",
32
+ "windsurf",
33
+ "antigravity",
30
34
  "mcp",
31
35
  "engineering-memory",
32
36
  "knowledge-graph",