@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 +16 -8
- package/dist/cli/index.js +125 -48
- 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/dist/synthesis/provider.js +145 -37
- package/dist/synthesis/synthesize.js +4 -4
- 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,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
|
-
|
|
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
|
|
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
|
|
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(", ")}`);
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
1640
|
+
// ---- hook (multi-agent lifecycle hook handler) ----------------------------
|
|
1585
1641
|
program
|
|
1586
1642
|
.command("hook")
|
|
1587
|
-
.description("
|
|
1588
|
-
.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
2665
|
-
|
|
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
|
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
|
|
@@ -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
|
|
5
|
-
* CLI — never
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
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
|
|
461
|
-
//
|
|
462
|
-
|
|
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
|
|
477
|
+
let v = availCache.get(p);
|
|
466
478
|
if (!v) {
|
|
467
479
|
v = p.available().catch(() => false);
|
|
468
|
-
availCache.set(p
|
|
480
|
+
availCache.set(p, v);
|
|
469
481
|
}
|
|
470
482
|
return v;
|
|
471
483
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
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
|
|
606
|
-
return
|
|
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.
|
|
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",
|