@davesheffer/hunch 0.29.0 β 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli/index.js +32 -0
- package/dist/core/conformance.js +57 -0
- package/dist/core/types.js +13 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/integrations/providers.js +35 -0
- package/dist/mcp/server.js +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ cd your-repo && hunch init && hunch backfill --since 90d
|
|
|
18
18
|
hunch why src/some/file.ts # β¦or just ask Claude Code: "why is X built this way?"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
<sub>Works with **Claude Code, Cursor, Copilot &
|
|
21
|
+
<sub>Works with **Claude Code, Cursor, Copilot, Windsurf & Google Antigravity** from one shared graph.</sub>
|
|
22
22
|
|
|
23
23
|
### π **[Read the full documentation β hunch-pi.vercel.app/docs](https://hunch-pi.vercel.app/docs)**
|
|
24
24
|
|
|
@@ -96,7 +96,7 @@ hunch why src/auth/session.ts # β¦then ask your assistant: "why is X buil
|
|
|
96
96
|
|
|
97
97
|
`hunch init` scaffolds `.hunch/`, indexes the repo, installs the git hooks + merge driver,
|
|
98
98
|
writes `.mcp.json` + slash commands + an auto-maintained `CLAUDE.md`, and wires up **every
|
|
99
|
-
detected assistant** (Claude Code, Cursor, VS Code/Copilot, Windsurf, Codex) to the same
|
|
99
|
+
detected assistant** (Claude Code, Cursor, VS Code/Copilot, Windsurf, Codex, Google Antigravity) to the same
|
|
100
100
|
graph β merging idempotently into existing files. **Reload your assistant in the repo**
|
|
101
101
|
afterward to pick up the `hunch_*` tools. Each teammate runs `hunch init` once; the
|
|
102
102
|
`.hunch/` content is shared via git.
|
package/dist/cli/index.js
CHANGED
|
@@ -47,6 +47,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
|
|
|
47
47
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
48
48
|
import { computeDrift } from "../core/drift.js";
|
|
49
49
|
import { compareCandidates } from "../core/compare.js";
|
|
50
|
+
import { checkConformance } from "../core/conformance.js";
|
|
50
51
|
import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
|
|
51
52
|
import { constraintId } from "../core/ids.js";
|
|
52
53
|
import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
|
|
@@ -598,6 +599,37 @@ program
|
|
|
598
599
|
console.log(`β captured ${dec} decision(s) + ${con} constraint(s) from inline comments${opts.private ? " [private overlay]" : ""}`);
|
|
599
600
|
store.close();
|
|
600
601
|
});
|
|
602
|
+
// ---- conform (intent-conformance: does code still satisfy the recorded why) ----
|
|
603
|
+
program
|
|
604
|
+
.command("conform")
|
|
605
|
+
.description("Intent-conformance: prove the code still SATISFIES each in-force decision's recorded intent (deterministic, over the graph). Surfaces where code drifted from the why β even with no diff in scope.")
|
|
606
|
+
.option("--strict", "exit non-zero if any intent is violated")
|
|
607
|
+
.action((opts) => {
|
|
608
|
+
const { store } = storeFor();
|
|
609
|
+
store.reindex();
|
|
610
|
+
const results = checkConformance(store);
|
|
611
|
+
if (!results.length) {
|
|
612
|
+
console.log("No conformance predicates recorded yet.");
|
|
613
|
+
console.log(dim(" Add a `conformance` predicate to a decision (e.g. { assert: \"calls\", subject: \"pay\", object: \"verifySession\" }) to prove the code honors its intent."));
|
|
614
|
+
store.close();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const violations = results.filter((r) => !r.satisfied);
|
|
618
|
+
console.log(`Intent-conformance: ${results.length - violations.length}/${results.length} satisfied\n`);
|
|
619
|
+
for (const r of results) {
|
|
620
|
+
console.log(` ${r.satisfied ? "β
" : "β"} ${r.decision} β "${r.title}"`);
|
|
621
|
+
console.log(` ${r.assert} ${r.subject}${r.object ? ` β ${r.object}` : ""}: ${r.detail}`);
|
|
622
|
+
}
|
|
623
|
+
if (violations.length) {
|
|
624
|
+
console.log(`\nβ ${violations.length} intent(s) the code no longer satisfies.`);
|
|
625
|
+
if (opts.strict)
|
|
626
|
+
process.exitCode = 1;
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
console.log(`\nβ
the code satisfies every recorded intent.`);
|
|
630
|
+
}
|
|
631
|
+
store.close();
|
|
632
|
+
});
|
|
601
633
|
// ---- compare (rank N candidate solutions by architectural fit) ------------
|
|
602
634
|
program
|
|
603
635
|
.command("compare")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function resolveSymbol(store, ref) {
|
|
2
|
+
const syms = store.recs("symbols");
|
|
3
|
+
if (ref.startsWith("sym_"))
|
|
4
|
+
return syms.find((s) => s.id === ref) ?? null;
|
|
5
|
+
if (ref.includes(":")) {
|
|
6
|
+
const [f, n] = ref.split(":");
|
|
7
|
+
return syms.find((s) => s.name === n && (s.file === f || s.file.endsWith("/" + (f ?? "")))) ?? null;
|
|
8
|
+
}
|
|
9
|
+
return syms.find((s) => s.name === ref) ?? null;
|
|
10
|
+
}
|
|
11
|
+
function reaches(store, id, transitive) {
|
|
12
|
+
const set = new Set();
|
|
13
|
+
for (const d of store.getDependencies(id, transitive ? 6 : 1)) {
|
|
14
|
+
if (transitive || d.depth === 1)
|
|
15
|
+
set.add(d.id);
|
|
16
|
+
}
|
|
17
|
+
return set;
|
|
18
|
+
}
|
|
19
|
+
function evalPredicate(store, d, p) {
|
|
20
|
+
const base = { decision: d.id, title: d.title, assert: p.assert, subject: p.subject, object: p.object };
|
|
21
|
+
const subj = resolveSymbol(store, p.subject);
|
|
22
|
+
if (p.assert === "exists") {
|
|
23
|
+
return { ...base, satisfied: !!subj, detail: subj ? `${p.subject} exists (${subj.file})` : `${p.subject} no longer exists in the graph` };
|
|
24
|
+
}
|
|
25
|
+
if (!subj)
|
|
26
|
+
return { ...base, satisfied: false, detail: `subject "${p.subject}" not found in the graph β intent's subject is gone` };
|
|
27
|
+
const wantReach = p.assert === "calls" || p.assert === "imports";
|
|
28
|
+
const obj = p.object ? resolveSymbol(store, p.object) : null;
|
|
29
|
+
if (!obj) {
|
|
30
|
+
// a required target gone β the link can't hold (violated); a forbidden one trivially holds.
|
|
31
|
+
return { ...base, satisfied: !wantReach, detail: `target "${p.object ?? ""}" not found in the graph` };
|
|
32
|
+
}
|
|
33
|
+
const linked = reaches(store, subj.id, p.transitive).has(obj.id);
|
|
34
|
+
const satisfied = wantReach ? linked : !linked;
|
|
35
|
+
const via = p.transitive ? " (transitively)" : "";
|
|
36
|
+
const detail = satisfied
|
|
37
|
+
? wantReach
|
|
38
|
+
? `${subj.name} β${via} ${obj.name} β`
|
|
39
|
+
: `${subj.name} does not reach ${obj.name} β`
|
|
40
|
+
: wantReach
|
|
41
|
+
? `${subj.name} no longer reaches${via} ${obj.name} β intent VIOLATED`
|
|
42
|
+
: `${subj.name} now reaches${via} ${obj.name} β intent VIOLATED`;
|
|
43
|
+
return { ...base, satisfied, detail };
|
|
44
|
+
}
|
|
45
|
+
/** Check every in-force decision's conformance predicates against the CURRENT graph.
|
|
46
|
+
* `.satisfied === false` means the code drifted from the recorded intent. Deterministic. */
|
|
47
|
+
export function checkConformance(store) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const d of store.recs("decisions")) {
|
|
50
|
+
if (d.status === "superseded" || d.superseded_by)
|
|
51
|
+
continue; // in-force decisions only
|
|
52
|
+
for (const p of d.conformance ?? [])
|
|
53
|
+
out.push(evalPredicate(store, d, p));
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=conformance.js.map
|
package/dist/core/types.js
CHANGED
|
@@ -94,6 +94,18 @@ export const RejectedTripwireSchema = z.object({
|
|
|
94
94
|
provenance: ProvenanceSchema,
|
|
95
95
|
});
|
|
96
96
|
/** ADR-style decision record, auto-drafted and human-confirmable. */
|
|
97
|
+
/** Intent-conformance predicate (the "inversion": prove the code still SATISFIES a
|
|
98
|
+
* decision's intent, not just that a diff didn't touch a guarded file). Each predicate
|
|
99
|
+
* compiles a decision's intent into a DETERMINISTIC check over the symbol/dependency
|
|
100
|
+
* graph Hunch already builds β no model. "pay must verify the session" becomes
|
|
101
|
+
* { assert: "calls", subject: "pay", object: "verifySession" }; if pay stops calling
|
|
102
|
+
* verifySession the intent is VIOLATED even with no diff in scope. */
|
|
103
|
+
export const ConformancePredicateSchema = z.object({
|
|
104
|
+
assert: z.enum(["calls", "not-calls", "imports", "not-imports", "exists"]),
|
|
105
|
+
subject: z.string().describe("symbol name / id / file:name the intent is about"),
|
|
106
|
+
object: z.string().optional().describe("required (calls/imports) or forbidden (not-*) target"),
|
|
107
|
+
transitive: z.boolean().default(false).describe("allow an indirect path over the dependency graph"),
|
|
108
|
+
});
|
|
97
109
|
export const DecisionSchema = z.object({
|
|
98
110
|
id: z.string().describe("dec_*"),
|
|
99
111
|
title: z.string(),
|
|
@@ -117,6 +129,7 @@ export const DecisionSchema = z.object({
|
|
|
117
129
|
valid_from: z.string().optional().describe("ISO instant the decision took effect (commit date)"),
|
|
118
130
|
valid_to: z.string().nullable().default(null).describe("ISO instant it was superseded (null = in force)"),
|
|
119
131
|
retired: RetiredSignalSchema.default({ symbols: [], deps: [] }),
|
|
132
|
+
conformance: z.array(ConformancePredicateSchema).optional().describe("deterministic intent-conformance checks over the graph"),
|
|
120
133
|
provenance: ProvenanceSchema,
|
|
121
134
|
date: z.string(),
|
|
122
135
|
});
|
|
@@ -34,6 +34,7 @@ export function renderHunchSection(store) {
|
|
|
34
34
|
lines.push("- `hunch_query(query)` β free-text search across all of Hunch.");
|
|
35
35
|
lines.push("- `hunch_runbook(task)` β the proven steps for a recurring task (e.g. \"add an MCP tool\", \"cut a release\").");
|
|
36
36
|
lines.push("- `hunch_compare(candidates)` β rank N candidate branches/commits by architectural fit (fewest invariant hits).");
|
|
37
|
+
lines.push("- `hunch_conformance()` β does the code still SATISFY recorded intent? (e.g. `pay` still reaches `verifySession`). Run before a refactor.");
|
|
37
38
|
lines.push("- `hunch_record_decision(...)` β write back a decision after a non-trivial choice.");
|
|
38
39
|
if (constraints.length) {
|
|
39
40
|
lines.push("");
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* and is idempotent, so re-running `hunch init` is safe.
|
|
19
19
|
*/
|
|
20
20
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
21
22
|
import { join, dirname } from "node:path";
|
|
22
23
|
import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
|
|
23
24
|
/** Strip // line and block comments + trailing commas (JSONC β JSON). String-aware
|
|
@@ -153,6 +154,37 @@ export function writeVscodeMcp(root, inv) {
|
|
|
153
154
|
json.servers.hunch = { type: "stdio", command: inv.command, args: [...inv.args, "mcp"] };
|
|
154
155
|
return writeJson(file, json);
|
|
155
156
|
}
|
|
157
|
+
/** Google Antigravity's MCP config is GLOBAL (user home), not project-local β and the
|
|
158
|
+
* dir moved between versions (`antigravity/` vs `config/`). Resolve adaptively: an
|
|
159
|
+
* existing config wins, else an existing parent dir, else null (Antigravity not
|
|
160
|
+
* installed β we never create a global config for an absent tool). `home` is injectable
|
|
161
|
+
* for tests so we never touch the real ~/.gemini. */
|
|
162
|
+
export function antigravityMcpFile(home = homedir()) {
|
|
163
|
+
const candidates = [
|
|
164
|
+
join(home, ".gemini", "antigravity", "mcp_config.json"),
|
|
165
|
+
join(home, ".gemini", "config", "mcp_config.json"),
|
|
166
|
+
];
|
|
167
|
+
for (const c of candidates)
|
|
168
|
+
if (existsSync(c))
|
|
169
|
+
return c;
|
|
170
|
+
for (const c of candidates)
|
|
171
|
+
if (existsSync(dirname(c)))
|
|
172
|
+
return c;
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/** Antigravity: merge the hunch stdio server into the global mcp_config.json β same
|
|
176
|
+
* `mcpServers` { command, args } shape as Cursor/Claude (stdio; `serverUrl` is only for
|
|
177
|
+
* HTTP servers). Returns null when Antigravity isn't detected. Grounding needs nothing
|
|
178
|
+
* extra: Antigravity reads the project-root AGENTS.md Hunch already writes. */
|
|
179
|
+
export function writeAntigravityMcp(inv, home = homedir()) {
|
|
180
|
+
const file = antigravityMcpFile(home);
|
|
181
|
+
if (!file)
|
|
182
|
+
return null;
|
|
183
|
+
const json = readJsonObj(file);
|
|
184
|
+
json.mcpServers = json.mcpServers ?? {};
|
|
185
|
+
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
186
|
+
return writeJson(file, json);
|
|
187
|
+
}
|
|
156
188
|
const TOML_START = "# >>> hunch mcp (managed) >>>";
|
|
157
189
|
const TOML_END = "# <<< hunch mcp <<<";
|
|
158
190
|
/** Codex CLI: .codex/config.toml β `[mcp_servers.hunch]` stdio entry. We own only
|
|
@@ -278,6 +310,9 @@ export function scaffoldProviders(root, inv, store) {
|
|
|
278
310
|
["VS Code (Copilot)", () => [writeVscodeMcp(root, inv), writeCopilotInstructions(root, store)]],
|
|
279
311
|
["Codex CLI", () => [writeCodexConfig(root, inv)]],
|
|
280
312
|
["Windsurf", () => [writeWindsurfMcp(root, inv), writeWindsurfRule(root, store)]],
|
|
313
|
+
// Antigravity reads project-root AGENTS.md for grounding (written below); its MCP
|
|
314
|
+
// config is global + detection-gated, so it only writes when Antigravity is installed.
|
|
315
|
+
["Google Antigravity", () => { const f = writeAntigravityMcp(inv); return f ? [f] : []; }],
|
|
281
316
|
["Any (AGENTS.md)", () => [writeAgentsMd(root, store)]],
|
|
282
317
|
];
|
|
283
318
|
return tasks.map(([assistant, run]) => {
|
package/dist/mcp/server.js
CHANGED
|
@@ -17,6 +17,7 @@ import { buildCorrectionConstraint } from "../core/correction.js";
|
|
|
17
17
|
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, commitAndPushHunch } from "../extractors/git.js";
|
|
18
18
|
import { formatContext } from "../core/format.js";
|
|
19
19
|
import { compareCandidates } from "../core/compare.js";
|
|
20
|
+
import { checkConformance } from "../core/conformance.js";
|
|
20
21
|
import { renderMarkdown, verdict } from "../core/checkreport.js";
|
|
21
22
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
22
23
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
@@ -435,6 +436,20 @@ export function buildServer(root) {
|
|
|
435
436
|
return err(`Failed to compare candidates: ${e.message}`);
|
|
436
437
|
}
|
|
437
438
|
});
|
|
439
|
+
// -- hunch_conformance ----------------------------------------------------
|
|
440
|
+
server.registerTool("hunch_conformance", {
|
|
441
|
+
title: "Does the code still satisfy the recorded intent?",
|
|
442
|
+
description: "Intent-conformance (the inversion of a normal guard): for every in-force decision carrying a conformance predicate, deterministically verify the CODE still satisfies its intent over the dependency graph β e.g. 'pay still reaches verifySession'. Returns the violations: intent the code has silently drifted away from, with NO diff required. Run before a refactor or merge to catch intent erosion a diff-only check can't see.",
|
|
443
|
+
inputSchema: {},
|
|
444
|
+
}, async () => {
|
|
445
|
+
const results = checkConformance(store);
|
|
446
|
+
if (!results.length)
|
|
447
|
+
return ok("No conformance predicates recorded. Add a `conformance` predicate to a decision (e.g. {assert:'calls', subject:'pay', object:'verifySession'}) to prove the code honors its intent.");
|
|
448
|
+
const violations = results.filter((r) => !r.satisfied);
|
|
449
|
+
const lines = results.map((r) => `${r.satisfied ? "β
" : "β"} ${r.decision} "${r.title}" β ${r.assert} ${r.subject}${r.object ? ` β ${r.object}` : ""}: ${r.detail}`);
|
|
450
|
+
const head = violations.length ? `β ${violations.length} intent(s) the code no longer satisfies` : "β
the code satisfies every recorded intent";
|
|
451
|
+
return ok(`Intent-conformance (${results.length - violations.length}/${results.length} satisfied):\n\n${lines.join("\n")}\n\n${head}`);
|
|
452
|
+
});
|
|
438
453
|
return server;
|
|
439
454
|
}
|
|
440
455
|
function provLine(record) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Hunch β an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
|