@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 +10 -8
- package/dist/cli/index.js +50 -28
- package/dist/core/agenthook.js +197 -0
- package/dist/core/config.js +1 -1
- package/dist/core/hookcache.js +1 -1
- package/dist/integrations/providers.js +177 -23
- package/package.json +6 -2
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
178
|
-
//
|
|
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:
|
|
184
|
-
//
|
|
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
|
|
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
|
|
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 (
|
|
1585
|
+
// ---- hook (multi-agent lifecycle hook handler) ----------------------------
|
|
1585
1586
|
program
|
|
1586
1587
|
.command("hook")
|
|
1587
|
-
.description("
|
|
1588
|
-
.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2665
|
-
|
|
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
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
*
|
|
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"];
|
package/dist/core/hookcache.js
CHANGED
|
@@ -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
|
|
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
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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", () =>
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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.
|
|
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",
|