@davesheffer/hunch 1.5.0 → 1.6.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,20 @@ 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
 
64
65
  ```text
65
- Claude Code ─┐
66
- Cursor ├── MCP ──> .hunch/ reasoning graph ──> deterministic checks
67
- Copilot
68
- Codex
69
- Windsurf ─┘
66
+ Claude Code ─┐
67
+ Cursor ├── MCP ──> .hunch/ reasoning graph ──> deterministic checks
68
+ Copilot
69
+ Codex
70
+ Windsurf
71
+ Antigravity ┘
70
72
  ```
71
73
 
72
74
  ## The Change Gate: review intent, not just code
package/dist/cli/index.js CHANGED
@@ -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(", ")}`);
@@ -1527,7 +1528,7 @@ program
1527
1528
  // ---- firmness (agent-hook enforcement level) ------------------------------
1528
1529
  program
1529
1530
  .command("firmness")
1530
- .description("Get or set how firmly the Claude Code agent hook enforces Hunch before edits.")
1531
+ .description("Get or set how firmly agent lifecycle hooks enforce Hunch before edits.")
1531
1532
  .argument("[level]", "off | advisory | firm | strict (omit to print the current level)")
1532
1533
  .action((level) => {
1533
1534
  const paths = hunchPaths(findRoot());
@@ -1540,7 +1541,7 @@ program
1540
1541
  return fail(`firmness must be one of: ${FIRMNESS_LEVELS.join(", ")}`);
1541
1542
  }
1542
1543
  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).`);
1544
+ console.log(`✓ firmness set to ${next} (takes effect on the next agent edit — no restart needed).`);
1544
1545
  });
1545
1546
  // ---- status (enforcement readiness at a glance) ---------------------------
1546
1547
  program
@@ -1581,16 +1582,22 @@ program
1581
1582
  console.log("");
1582
1583
  store.close();
1583
1584
  });
1584
- // ---- hook (Claude Code agent-hook handler) --------------------------------
1585
+ // ---- hook (multi-agent lifecycle hook handler) ----------------------------
1585
1586
  program
1586
1587
  .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 () => {
1588
+ .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.")
1589
+ .option("--provider <provider>", "hook event dialect: claude | vscode | cursor | windsurf | antigravity", "claude")
1590
+ .action(async (opts) => {
1589
1591
  // A hook MUST NEVER break the agent: on ANY error or unrecognized input we
1590
1592
  // emit nothing and exit 0 (the action defers to Claude Code's normal flow).
1591
1593
  let store = null;
1592
1594
  try {
1593
- const evt = JSON.parse(await readStdin());
1595
+ const provider = hookProvider(opts.provider);
1596
+ if (!provider)
1597
+ return;
1598
+ const evt = normalizeHookEvent(JSON.parse(await readStdin()), provider);
1599
+ if (!evt)
1600
+ return;
1594
1601
  const root = findRoot();
1595
1602
  const paths = hunchPaths(root);
1596
1603
  const firmness = readConfig(paths).firmness;
@@ -1620,7 +1627,7 @@ program
1620
1627
  const verdict = stopVerdict(st, firmness);
1621
1628
  if (verdict.block) {
1622
1629
  savePipelineState(evt.session_id, verdict.state);
1623
- process.stdout.write(JSON.stringify({ decision: "block", reason: verdict.reason }));
1630
+ emitStop(provider, verdict.reason);
1624
1631
  }
1625
1632
  return;
1626
1633
  }
@@ -1642,7 +1649,7 @@ program
1642
1649
  // different content, so it always comes through (dec_244397d920).
1643
1650
  if (injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
1644
1651
  return;
1645
- emitContext("UserPromptSubmit", text);
1652
+ emitContext(provider, "UserPromptSubmit", text);
1646
1653
  return;
1647
1654
  }
1648
1655
  if (evt.hook_event_name === "SessionStart") {
@@ -1658,7 +1665,7 @@ program
1658
1665
  if (!decisions.length) {
1659
1666
  // Fresh graph: nothing to orient on, but the operating loop still ships.
1660
1667
  if (pipelineEnabled())
1661
- emitContext("SessionStart", PIPELINE_LOOP);
1668
+ emitContext(provider, "SessionStart", PIPELINE_LOOP);
1662
1669
  return;
1663
1670
  }
1664
1671
  const L = [];
@@ -1678,7 +1685,13 @@ program
1678
1685
  // (the zod bench showed ambient skills are read in ~0% of sessions).
1679
1686
  if (pipelineEnabled())
1680
1687
  L.push("", PIPELINE_LOOP);
1681
- emitContext("SessionStart", L.join("\n"));
1688
+ const orientation = L.join("\n");
1689
+ // Antigravity's nearest equivalent is PreInvocation, which can fire
1690
+ // repeatedly in one conversation. Deduplicate it just like edit
1691
+ // grounding so it remains an orientation, not a context flood.
1692
+ if (provider === "antigravity" && injectionMode(evt.session_id, "orientation", orientation) === "delta")
1693
+ return;
1694
+ emitContext(provider, "SessionStart", orientation);
1682
1695
  }
1683
1696
  finally {
1684
1697
  s.close();
@@ -1710,7 +1723,7 @@ program
1710
1723
  const proposedLines = proposedEditLines(evt.tool_input);
1711
1724
  const deny = blockingInScope(store, target, proposedLines);
1712
1725
  if (deny) {
1713
- emitDeny(deny.reason);
1726
+ emitDeny(provider, deny.reason);
1714
1727
  return;
1715
1728
  }
1716
1729
  // Veto Guard (live): the proposed edit text re-introduces an approach an
@@ -1718,7 +1731,7 @@ program
1718
1731
  // only human-confirmed tripwires deny.
1719
1732
  const vetoDeny = proposedLines.length ? vetoInScope(store, target, proposedLines) : null;
1720
1733
  if (vetoDeny) {
1721
- emitDeny(vetoDeny.reason);
1734
+ emitDeny(provider, vetoDeny.reason);
1722
1735
  return;
1723
1736
  }
1724
1737
  }
@@ -1761,10 +1774,10 @@ program
1761
1774
  // the full 10-16KB block. Any record change re-sends the full text; the
1762
1775
  // strict-gate deny path above never routes through this (dec_244397d920).
1763
1776
  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).`);
1777
+ 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
1778
  return;
1766
1779
  }
1767
- emitContext("PreToolUse", text);
1780
+ emitContext(provider, "PreToolUse", text);
1768
1781
  }
1769
1782
  catch {
1770
1783
  // swallow — never block an edit on a hook failure
@@ -2657,13 +2670,22 @@ function realpathNorm(p) {
2657
2670
  function toRepoRel(root, abs) {
2658
2671
  return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
2659
2672
  }
2660
- function emitContext(event, text) {
2661
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }));
2673
+ function emitContext(provider, event, text) {
2674
+ const output = contextHookOutput(provider, event, text);
2675
+ if (output)
2676
+ process.stdout.write(JSON.stringify(output));
2662
2677
  }
2663
- function emitDeny(reason) {
2664
- process.stdout.write(JSON.stringify({
2665
- hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
2666
- }));
2678
+ function emitDeny(provider, reason) {
2679
+ const result = denyHookOutput(provider, reason);
2680
+ if (result.output)
2681
+ process.stdout.write(JSON.stringify(result.output));
2682
+ if (result.stderr)
2683
+ process.stderr.write(`${result.stderr}\n`);
2684
+ if (result.exitCode !== undefined)
2685
+ process.exitCode = result.exitCode;
2686
+ }
2687
+ function emitStop(provider, reason) {
2688
+ process.stdout.write(JSON.stringify(stopHookOutput(provider, reason)));
2667
2689
  }
2668
2690
  program.parseAsync().catch((e) => {
2669
2691
  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
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.5.0",
3
+ "version": "1.6.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",