@davesheffer/hunch 0.4.0 → 0.8.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 +67 -8
- package/dist/cli/index.js +205 -15
- package/dist/core/config.js +35 -0
- package/dist/core/hookpolicy.js +22 -0
- package/dist/core/paths.js +1 -0
- package/dist/integrations/claudemd.js +13 -10
- package/dist/integrations/providers.js +229 -0
- package/dist/integrations/scaffold.js +57 -1
- package/dist/mcp/server.js +35 -0
- package/dist/synthesis/provider.js +150 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,13 +56,21 @@ Either way you then type `hunch …`. From a source checkout without `npm link`,
|
|
|
56
56
|
`node dist/cli/index.js …` (or `npm run hunch -- …` to run via tsx). The rest of this
|
|
57
57
|
README uses `hunch` for brevity.
|
|
58
58
|
|
|
59
|
-
### 2. (Recommended) make
|
|
59
|
+
### 2. (Recommended) make a coding-assistant CLI available
|
|
60
60
|
|
|
61
|
-
Hunch's LLM synthesis is billed to your
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
Hunch's LLM synthesis is billed to **your subscription** through a coding-assistant
|
|
62
|
+
CLI — **never** a pay-per-token API key (the API key is stripped from the child env).
|
|
63
|
+
Hunch auto-detects the first one present, in this order:
|
|
64
|
+
|
|
65
|
+
| CLI | Subscription | Detected by |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `claude` (Claude Code) | Claude Pro/Max | `claude --version` |
|
|
68
|
+
| `codex` (OpenAI Codex) | ChatGPT Plus/Pro | `codex --version` |
|
|
69
|
+
| `cursor-agent` (Cursor) | Cursor | `cursor-agent --version` |
|
|
70
|
+
|
|
71
|
+
If none is installed, Hunch still works using a deterministic structural heuristic
|
|
72
|
+
(lower-confidence drafts). `hunch doctor` tells you which mode you're in; force one
|
|
73
|
+
with `HUNCH_SYNTH_PROVIDER=claude-cli|codex-cli|cursor-agent|deterministic`.
|
|
66
74
|
|
|
67
75
|
### 3. Initialize the repo you want a memory for
|
|
68
76
|
|
|
@@ -98,18 +106,38 @@ normally and Claude consults Hunch, or invoke the slash commands:
|
|
|
98
106
|
| `/hunch-fragile` | a fragility report (the riskiest code, with evidence) |
|
|
99
107
|
|
|
100
108
|
The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
|
|
101
|
-
`hunch_check_constraints`, `hunch_get_dependents` (blast radius), `
|
|
109
|
+
`hunch_check_constraints`, `hunch_get_dependents` (blast radius), `hunch_blast_radius`
|
|
110
|
+
(dependent files + near-violations a change could break indirectly), `hunch_bug_lineage`,
|
|
102
111
|
`hunch_context` (surgical minimal slice for a task), `hunch_record_decision` (write-back).
|
|
103
112
|
|
|
113
|
+
### Works with any MCP assistant
|
|
114
|
+
|
|
115
|
+
The Hunch MCP server is **client-agnostic** — one `.hunch/` graph powers every
|
|
116
|
+
assistant. `hunch init` scaffolds each tool's MCP config + ambient grounding so
|
|
117
|
+
they all consult the same memory:
|
|
118
|
+
|
|
119
|
+
| Assistant | MCP config | Grounding file |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| Claude Code | `.mcp.json` | `CLAUDE.md` + `/hunch-*` slash commands |
|
|
122
|
+
| Cursor | `.cursor/mcp.json` | `.cursor/rules/hunch.mdc` (always-applied) |
|
|
123
|
+
| VS Code (Copilot) | `.vscode/mcp.json` | `.github/copilot-instructions.md` |
|
|
124
|
+
| Codex CLI | `.codex/config.toml` | `AGENTS.md` |
|
|
125
|
+
| Anything else | — | `AGENTS.md` (cross-tool standard) |
|
|
126
|
+
|
|
127
|
+
Each writer **merges** into existing files (other MCP servers and your own prose are
|
|
128
|
+
preserved) and is idempotent. Opt out with `hunch init --no-providers`.
|
|
129
|
+
|
|
104
130
|
**Through the CLI** — the same graph, from your terminal:
|
|
105
131
|
|
|
106
132
|
| Command | What |
|
|
107
133
|
|---|---|
|
|
108
|
-
| `hunch init
|
|
134
|
+
| `hunch init` | scaffold `.hunch/`, index, install hook + merge driver, auto-install the advisory pre-commit guard, install the **Claude Code agent hooks**, and wire up **every assistant** (Claude Code, Cursor, VS Code/Copilot, Codex, AGENTS.md). Flags: `--no-enforce`, `--enforce-strict`, `--no-providers`, `--no-agent-hooks`, `--firmness <level>` |
|
|
109
135
|
| `hunch index` | parse repo → symbols / edges / components (deterministic, no LLM) |
|
|
110
136
|
| `hunch backfill --since 90d` | replay git history → seed decisions |
|
|
111
137
|
| `hunch sync [sha]` | turn a commit into a Decision (run automatically by the hook) |
|
|
112
138
|
| `hunch record-bug --test <id> --message <m>` | capture a Bug from a failing test |
|
|
139
|
+
| `hunch record-constraint "<statement>" [--scope <globs>] [--severity advisory\|warning\|blocking] [--type …] [--rationale <t>] [--source-decision <id>]` | record an invariant the code must not break (what `hunch check` + the strict agent hook enforce) |
|
|
140
|
+
| `hunch firmness [off\|advisory\|firm\|strict]` | get/set how firmly the agent hook enforces Hunch before edits (no arg prints the current level) |
|
|
113
141
|
| `hunch test [cmd…]` | run the suite (default `npm test`); auto-capture failures as Bugs (suspects + recurrence→Constraints), mark passing tests' bugs fixed |
|
|
114
142
|
| `hunch why <path\|symbol>` | decisions / bugs / constraints explaining a target (flags `⚠STALE`) |
|
|
115
143
|
| `hunch query "<q>" [--semantic]` | full-text + graph search (`--semantic` blends in local embeddings) |
|
|
@@ -124,6 +152,37 @@ The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
|
|
|
124
152
|
| `hunch doctor` | environment diagnostics (git, auth mode, schema version, counts) |
|
|
125
153
|
| `hunch mcp` | start the MCP server over stdio (Claude Code connects here) |
|
|
126
154
|
|
|
155
|
+
## Grounding the agent automatically (firmness)
|
|
156
|
+
|
|
157
|
+
Telling an assistant "consult Hunch first" in a prompt is advisory — it drifts. `hunch
|
|
158
|
+
init` instead installs two **Claude Code agent hooks** (in `.claude/settings.json`) so the
|
|
159
|
+
grounding is enforced by the harness, not by the model's memory:
|
|
160
|
+
|
|
161
|
+
- **Before every edit** (`PreToolUse` on `Edit`/`Write`/`MultiEdit`) Hunch injects the
|
|
162
|
+
relevant slice for the file being touched — its decisions, invariants, bug history, and
|
|
163
|
+
blast radius — straight into the model's context.
|
|
164
|
+
- **On every prompt** (`UserPromptSubmit`) it reminds the agent to query Hunch.
|
|
165
|
+
|
|
166
|
+
How hard it pushes is one committed knob — set it once, it applies to the whole team:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
hunch firmness # print the current level
|
|
170
|
+
hunch firmness strict # change it (takes effect on the next edit; no restart)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
| Level | Before an edit |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `off` | nothing (hook is a no-op) |
|
|
176
|
+
| `advisory` *(default)* | inject the relevant Hunch slice as context |
|
|
177
|
+
| `firm` | advisory **+** explicitly flag invariants in the file's scope |
|
|
178
|
+
| `strict` | firm **+** **deny** an edit that hits a *blocking* invariant (directly or via blast radius), feeding the invariant back as the refusal reason |
|
|
179
|
+
|
|
180
|
+
The hook never breaks your flow: any error or unrecognized input emits nothing and exits
|
|
181
|
+
0, and it stays silent on files Hunch hasn't learned yet. `strict` only bites once you have
|
|
182
|
+
**blocking** constraints recorded (`hunch record-constraint … --severity blocking`) — with
|
|
183
|
+
none, every level degrades to context-only. Opt out of the hooks entirely with `hunch init
|
|
184
|
+
--no-agent-hooks`.
|
|
185
|
+
|
|
127
186
|
## Semantic search (optional)
|
|
128
187
|
|
|
129
188
|
By default `hunch query` and the `hunch_query` MCP tool use fast keyword (FTS) search —
|
package/dist/cli/index.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
18
|
+
import { relative } from "node:path";
|
|
18
19
|
import { Command } from "commander";
|
|
19
20
|
import { hunchPaths, findRoot } from "../core/paths.js";
|
|
20
21
|
import { HunchStore } from "../store/hunchStore.js";
|
|
@@ -27,8 +28,12 @@ import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles
|
|
|
27
28
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
28
29
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
29
30
|
import { updateClaudeMd } from "../integrations/claudemd.js";
|
|
30
|
-
import { writeMcpJson, writeSlashCommands } from "../integrations/scaffold.js";
|
|
31
|
+
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
32
|
+
import { scaffoldProviders } from "../integrations/providers.js";
|
|
31
33
|
import { formatContext } from "../core/format.js";
|
|
34
|
+
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
35
|
+
import { blockingInScope } from "../core/hookpolicy.js";
|
|
36
|
+
import { constraintId } from "../core/ids.js";
|
|
32
37
|
import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
|
|
33
38
|
import { mergeHunchJson } from "../store/merge.js";
|
|
34
39
|
import { planCompaction } from "../store/compact.js";
|
|
@@ -47,9 +52,17 @@ program
|
|
|
47
52
|
.command("init")
|
|
48
53
|
.description("Scaffold .hunch/, index the repo, install the git hook, and wire up Claude Code.")
|
|
49
54
|
.option("--no-index", "skip the initial repo index")
|
|
50
|
-
.option("--enforce", "install
|
|
51
|
-
.option("--enforce-strict", "
|
|
55
|
+
.option("--no-enforce", "do not install the advisory pre-commit constraint guard")
|
|
56
|
+
.option("--enforce-strict", "make the pre-commit guard FAIL the commit on a blocking invariant (direct or near)")
|
|
57
|
+
.option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
|
|
58
|
+
.option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
|
|
59
|
+
.option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
|
|
52
60
|
.action((opts) => {
|
|
61
|
+
// Validate --firmness up front, before any side effects (indexing, git hooks,
|
|
62
|
+
// .mcp.json) or opening the store — a bad value must not leave a half-init.
|
|
63
|
+
if (opts.firmness !== undefined && !isFirmness(opts.firmness)) {
|
|
64
|
+
return fail(`--firmness must be one of: ${FIRMNESS_LEVELS.join(", ")}`);
|
|
65
|
+
}
|
|
53
66
|
const root = findRoot();
|
|
54
67
|
const paths = hunchPaths(root);
|
|
55
68
|
const store = new HunchStore(paths);
|
|
@@ -70,9 +83,13 @@ program
|
|
|
70
83
|
console.log(` ✓ post-commit hook ${h.action} (learning loop)`);
|
|
71
84
|
const m = installMergeDriver(root, inv.shell);
|
|
72
85
|
console.log(` ✓ team merge driver ${m.action}`);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
// Auto-install the pre-commit guard by default (advisory: flags invariants
|
|
87
|
+
// touched directly OR via blast radius, never blocks). Opt out with
|
|
88
|
+
// --no-enforce; --enforce-strict makes blocking near/direct hits fail the commit.
|
|
89
|
+
if (opts.enforce !== false || opts.enforceStrict) {
|
|
90
|
+
const strict = !!opts.enforceStrict;
|
|
91
|
+
const p = installPreCommitHook(root, inv.shell, strict);
|
|
92
|
+
console.log(` ✓ pre-commit constraint guard ${p.action} (${strict ? "strict — blocks on blocking invariants, direct or near" : "advisory — flags invariants in scope or blast radius"})`);
|
|
76
93
|
}
|
|
77
94
|
}
|
|
78
95
|
else {
|
|
@@ -84,6 +101,26 @@ program
|
|
|
84
101
|
console.log(` ✓ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
|
|
85
102
|
const cmd = updateClaudeMd(root, store);
|
|
86
103
|
console.log(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
|
|
104
|
+
// Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
|
|
105
|
+
// level even before the user runs `hunch firmness` (--firmness validated above).
|
|
106
|
+
const firmness = writeConfig(paths, opts.firmness ? { firmness: opts.firmness } : {}).firmness;
|
|
107
|
+
// Agent hooks: ground the assistant in Hunch automatically (PreToolUse injects
|
|
108
|
+
// context before edits; UserPromptSubmit reminds). Reads firmness at run time.
|
|
109
|
+
if (opts.agentHooks !== false) {
|
|
110
|
+
const a = installClaudeHooks(root, `${inv.shell} hook`);
|
|
111
|
+
console.log(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
|
|
112
|
+
}
|
|
113
|
+
// Multi-assistant compatibility: the MCP server is client-agnostic, so wire up
|
|
114
|
+
// Cursor / VS Code (Copilot) / Codex / AGENTS.md to the same .hunch/ graph.
|
|
115
|
+
if (opts.providers !== false) {
|
|
116
|
+
const ps = scaffoldProviders(root, inv.mcp, store);
|
|
117
|
+
const ok = ps.filter((p) => !p.error);
|
|
118
|
+
const total = ok.reduce((a, p) => a + p.files.length, 0);
|
|
119
|
+
console.log(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
|
|
120
|
+
for (const p of ps)
|
|
121
|
+
if (p.error)
|
|
122
|
+
console.log(` ⚠ skipped ${p.assistant}: ${p.error}`);
|
|
123
|
+
}
|
|
87
124
|
store.close();
|
|
88
125
|
console.log("\nNext: make a commit (the hook captures a decision), then ask Claude Code \"why is X built this way?\"");
|
|
89
126
|
console.log("Cold start? Seed from history: hunch backfill --since 90d");
|
|
@@ -320,6 +357,41 @@ program
|
|
|
320
357
|
console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
|
|
321
358
|
store.close();
|
|
322
359
|
});
|
|
360
|
+
// ---- record-constraint (human-authored invariant) -------------------------
|
|
361
|
+
program
|
|
362
|
+
.command("record-constraint")
|
|
363
|
+
.description("Record an invariant the codebase must not break — what `hunch check` and the strict agent hook enforce.")
|
|
364
|
+
.argument("<statement>", 'the invariant, e.g. "vectors are derived, never the source of truth"')
|
|
365
|
+
.option("--scope <globs>", "comma-separated path/glob(s) it applies to (e.g. src/store/**)", "")
|
|
366
|
+
.option("--severity <s>", "advisory | warning | blocking", "warning")
|
|
367
|
+
.option("--type <t>", "security | performance | correctness | architecture | compliance", "correctness")
|
|
368
|
+
.option("--rationale <text>", "why it must hold", "")
|
|
369
|
+
.option("--source-decision <id>", "decision id this derives from")
|
|
370
|
+
.option("--enforcement <e>", "advisory_v1 | ci | manual", "advisory_v1")
|
|
371
|
+
.action((statement, opts) => {
|
|
372
|
+
const SEV = ["advisory", "warning", "blocking"];
|
|
373
|
+
if (!SEV.includes(opts.severity))
|
|
374
|
+
return fail(`--severity must be one of: ${SEV.join(", ")}`);
|
|
375
|
+
const { store, root } = storeFor();
|
|
376
|
+
store.json.ensureDirs();
|
|
377
|
+
const scope = opts.scope.split(",").map((s) => s.trim()).filter(Boolean);
|
|
378
|
+
const c = store.json.put("constraints", {
|
|
379
|
+
id: constraintId(statement),
|
|
380
|
+
type: opts.type,
|
|
381
|
+
statement,
|
|
382
|
+
scope,
|
|
383
|
+
severity: opts.severity,
|
|
384
|
+
enforcement: opts.enforcement,
|
|
385
|
+
rationale: opts.rationale,
|
|
386
|
+
source_decision: opts.sourceDecision ?? null,
|
|
387
|
+
violations: [],
|
|
388
|
+
provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: new Date().toISOString() },
|
|
389
|
+
});
|
|
390
|
+
store.reindex();
|
|
391
|
+
updateClaudeMd(root, store);
|
|
392
|
+
console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})`);
|
|
393
|
+
store.close();
|
|
394
|
+
});
|
|
323
395
|
// ---- test (failure-learning loop) -----------------------------------------
|
|
324
396
|
program
|
|
325
397
|
.command("test")
|
|
@@ -518,6 +590,87 @@ program
|
|
|
518
590
|
process.stdout.write(formatContext(store.assembleContext(target, Number(opts.budget))));
|
|
519
591
|
store.close();
|
|
520
592
|
});
|
|
593
|
+
// ---- firmness (agent-hook enforcement level) ------------------------------
|
|
594
|
+
program
|
|
595
|
+
.command("firmness")
|
|
596
|
+
.description("Get or set how firmly the Claude Code agent hook enforces Hunch before edits.")
|
|
597
|
+
.argument("[level]", "off | advisory | firm | strict (omit to print the current level)")
|
|
598
|
+
.action((level) => {
|
|
599
|
+
const paths = hunchPaths(findRoot());
|
|
600
|
+
if (!level) {
|
|
601
|
+
console.log(`firmness: ${readConfig(paths).firmness}`);
|
|
602
|
+
console.log(`levels: ${FIRMNESS_LEVELS.join(" | ")} (set with: hunch firmness <level>)`);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (!isFirmness(level)) {
|
|
606
|
+
return fail(`firmness must be one of: ${FIRMNESS_LEVELS.join(", ")}`);
|
|
607
|
+
}
|
|
608
|
+
const next = writeConfig(paths, { firmness: level }).firmness;
|
|
609
|
+
console.log(`✓ firmness set to ${next} (takes effect on the next edit — no Claude Code restart needed).`);
|
|
610
|
+
});
|
|
611
|
+
// ---- hook (Claude Code agent-hook handler) --------------------------------
|
|
612
|
+
program
|
|
613
|
+
.command("hook")
|
|
614
|
+
.description("Claude Code hook handler: inject relevant Hunch context before edits (and, at strict firmness, deny edits that hit a blocking invariant). Reads the hook event JSON on stdin.")
|
|
615
|
+
.action(async () => {
|
|
616
|
+
// A hook MUST NEVER break the agent: on ANY error or unrecognized input we
|
|
617
|
+
// emit nothing and exit 0 (the action defers to Claude Code's normal flow).
|
|
618
|
+
let store = null;
|
|
619
|
+
try {
|
|
620
|
+
const evt = JSON.parse(await readStdin());
|
|
621
|
+
const root = findRoot();
|
|
622
|
+
const paths = hunchPaths(root);
|
|
623
|
+
const firmness = readConfig(paths).firmness;
|
|
624
|
+
if (firmness === "off")
|
|
625
|
+
return;
|
|
626
|
+
if (evt.hook_event_name === "UserPromptSubmit") {
|
|
627
|
+
emitContext("UserPromptSubmit", HOOK_REMINDER);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (evt.hook_event_name !== "PreToolUse")
|
|
631
|
+
return;
|
|
632
|
+
const abs = evt.tool_input?.file_path;
|
|
633
|
+
if (!abs)
|
|
634
|
+
return;
|
|
635
|
+
const target = toRepoRel(root, abs);
|
|
636
|
+
// Outside the repo (".." prefix) or on another drive (absolute, e.g. "D:/…")
|
|
637
|
+
// → nothing for Hunch to say.
|
|
638
|
+
if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
|
|
639
|
+
return;
|
|
640
|
+
store = new HunchStore(paths);
|
|
641
|
+
// strict: refuse an edit that hits a BLOCKING invariant (direct OR via blast
|
|
642
|
+
// radius), feeding the invariant statement back as the refusal reason. Reindex
|
|
643
|
+
// first so the blast radius reflects uncommitted edges — strict opts into the
|
|
644
|
+
// cost for correctness. Advisory/firm skip it: the hook fires on every edit,
|
|
645
|
+
// and decisions/constraints don't change between commits, so the committed
|
|
646
|
+
// index is good enough for grounding.
|
|
647
|
+
if (firmness === "strict") {
|
|
648
|
+
store.reindex();
|
|
649
|
+
const deny = blockingInScope(store, target);
|
|
650
|
+
if (deny) {
|
|
651
|
+
emitDeny(deny.reason);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
|
|
656
|
+
const ctx = store.assembleContext(target);
|
|
657
|
+
const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length;
|
|
658
|
+
if (!hasContent)
|
|
659
|
+
return; // no noise on files Hunch hasn't learned yet
|
|
660
|
+
let text = formatContext(ctx).trim();
|
|
661
|
+
if (firmness !== "advisory" && ctx.constraints.length) {
|
|
662
|
+
const names = ctx.constraints.map((c) => `[${c.severity}] ${c.statement}`).join("; ");
|
|
663
|
+
text += `\n\n⚠ This file is in scope of ${ctx.constraints.length} invariant(s): ${names}. Preserve them.`;
|
|
664
|
+
}
|
|
665
|
+
emitContext("PreToolUse", text);
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
// swallow — never block an edit on a hook failure
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
store?.close();
|
|
672
|
+
}
|
|
673
|
+
});
|
|
521
674
|
// ---- review (curate loop) -------------------------------------------------
|
|
522
675
|
program
|
|
523
676
|
.command("review")
|
|
@@ -672,16 +825,22 @@ program
|
|
|
672
825
|
console.log(`schema: v${onDisk} (hunch v${SCHEMA_VERSION})${schemaNote}`);
|
|
673
826
|
const provider = await selectProvider();
|
|
674
827
|
console.log(`synthesis: ${provider.name}`);
|
|
675
|
-
// Synthesis is billed to the user's
|
|
676
|
-
// never
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
828
|
+
// Synthesis is billed to the user's SUBSCRIPTION via a coding-assistant CLI,
|
|
829
|
+
// never a pay-per-token API key. Surface which one — or what's missing.
|
|
830
|
+
const SUB = {
|
|
831
|
+
"claude-cli": { label: "Claude subscription (claude CLI)", strip: "ANTHROPIC_API_KEY" },
|
|
832
|
+
"codex-cli": { label: "ChatGPT subscription (codex CLI)", strip: "OPENAI_API_KEY" },
|
|
833
|
+
"cursor-agent": { label: "Cursor subscription (cursor-agent CLI)" },
|
|
834
|
+
};
|
|
835
|
+
const sub = SUB[provider.name];
|
|
836
|
+
if (sub) {
|
|
837
|
+
const hadKey = sub.strip && !!process.env[sub.strip];
|
|
838
|
+
console.log(` ↳ LLM synthesis billed to your ${sub.label}` +
|
|
839
|
+
(hadKey ? ` (${sub.strip} in env is stripped — never billed to the API)` : ``));
|
|
681
840
|
}
|
|
682
|
-
else
|
|
683
|
-
console.log(dim(` ↳ no
|
|
684
|
-
console.log(dim(` for full synthesis
|
|
841
|
+
else {
|
|
842
|
+
console.log(dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence)`));
|
|
843
|
+
console.log(dim(` for full synthesis install one: Claude Code (\`claude /login\`), Codex (\`codex login\`), or Cursor (\`cursor-agent login\`)`));
|
|
685
844
|
}
|
|
686
845
|
const c = store.reindex().counts;
|
|
687
846
|
console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
|
|
@@ -708,6 +867,37 @@ function fail(msg) {
|
|
|
708
867
|
console.error(`error: ${msg}`);
|
|
709
868
|
process.exitCode = 1;
|
|
710
869
|
}
|
|
870
|
+
// --- agent-hook helpers (used by `hunch hook`) -----------------------------
|
|
871
|
+
const HOOK_REMINDER = "Hunch (engineering memory) is available for this repo. Before editing, call " +
|
|
872
|
+
"hunch_check_constraints(scope) for do-not-break invariants and hunch_why(target) " +
|
|
873
|
+
"for the rationale; use hunch_get_dependents for blast radius and hunch_bug_lineage " +
|
|
874
|
+
"for prior root causes. After a non-trivial choice, record it with hunch_record_decision.";
|
|
875
|
+
/** Read all of stdin (the hook event JSON). A TTY (no piped input) resolves to ""
|
|
876
|
+
* so an accidental interactive `hunch hook` exits cleanly instead of hanging. */
|
|
877
|
+
function readStdin() {
|
|
878
|
+
return new Promise((resolve) => {
|
|
879
|
+
if (process.stdin.isTTY)
|
|
880
|
+
return resolve("");
|
|
881
|
+
let data = "";
|
|
882
|
+
process.stdin.setEncoding("utf8");
|
|
883
|
+
process.stdin.on("data", (c) => (data += c));
|
|
884
|
+
process.stdin.on("end", () => resolve(data));
|
|
885
|
+
process.stdin.on("error", () => resolve(data));
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
/** Absolute edit path → repo-relative, forward-slash (constraint scopes are
|
|
889
|
+
* forward-slash globs even on Windows). */
|
|
890
|
+
function toRepoRel(root, abs) {
|
|
891
|
+
return relative(root, abs).split("\\").join("/");
|
|
892
|
+
}
|
|
893
|
+
function emitContext(event, text) {
|
|
894
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }));
|
|
895
|
+
}
|
|
896
|
+
function emitDeny(reason) {
|
|
897
|
+
process.stdout.write(JSON.stringify({
|
|
898
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
|
|
899
|
+
}));
|
|
900
|
+
}
|
|
711
901
|
program.parseAsync().catch((e) => {
|
|
712
902
|
try {
|
|
713
903
|
openStore?.close();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Hunch user config (`.hunch/config.json`) — runtime knobs that are NOT schema
|
|
2
|
+
* state (the on-disk schema version lives in manifest.json). Committed alongside
|
|
3
|
+
* the graph, so a whole team shares the same settings — e.g. how firmly the
|
|
4
|
+
* Claude Code agent hook enforces engineering memory before an edit. */
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
export const FIRMNESS_LEVELS = ["off", "advisory", "firm", "strict"];
|
|
8
|
+
export const DEFAULT_FIRMNESS = "advisory";
|
|
9
|
+
function defaults() {
|
|
10
|
+
return { firmness: DEFAULT_FIRMNESS };
|
|
11
|
+
}
|
|
12
|
+
export function isFirmness(v) {
|
|
13
|
+
return typeof v === "string" && FIRMNESS_LEVELS.includes(v);
|
|
14
|
+
}
|
|
15
|
+
/** Read `.hunch/config.json`. A missing/unparseable file, or an unknown firmness
|
|
16
|
+
* value, falls back to defaults — the hook must NEVER crash an edit over config. */
|
|
17
|
+
export function readConfig(paths) {
|
|
18
|
+
if (!existsSync(paths.config))
|
|
19
|
+
return defaults();
|
|
20
|
+
try {
|
|
21
|
+
const raw = JSON.parse(readFileSync(paths.config, "utf8"));
|
|
22
|
+
return { firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return defaults();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Write `.hunch/config.json`, merging `patch` over the current on-disk config. */
|
|
29
|
+
export function writeConfig(paths, patch) {
|
|
30
|
+
const next = { ...readConfig(paths), ...patch };
|
|
31
|
+
mkdirSync(dirname(paths.config), { recursive: true });
|
|
32
|
+
writeFileSync(paths.config, JSON.stringify(next, null, 2) + "\n");
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Return a BlockingHit if editing `file` (repo-relative) hits a blocking
|
|
2
|
+
* invariant directly or through its blast radius, else null. */
|
|
3
|
+
export function blockingInScope(store, file) {
|
|
4
|
+
for (const c of store.checkConstraints(file)) {
|
|
5
|
+
if (c.severity === "blocking") {
|
|
6
|
+
return {
|
|
7
|
+
reason: `Hunch: editing ${file} would touch a BLOCKING invariant — "${c.statement}" (${c.id}). Do not proceed unless this change is meant to modify that invariant; otherwise preserve it.`,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
for (const b of store.blastRadiusFiles(file)) {
|
|
12
|
+
for (const c of store.checkConstraints(b.file)) {
|
|
13
|
+
if (c.severity === "blocking") {
|
|
14
|
+
return {
|
|
15
|
+
reason: `Hunch: ${file} is in the blast radius of a BLOCKING invariant — "${c.statement}" (${c.id}; via ${b.file}, ${b.via} depth ${b.depth}). Verify the invariant still holds before editing.`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=hookpolicy.js.map
|
package/dist/core/paths.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* context loaded every session for free"). We own ONLY the region between the
|
|
4
4
|
* HUNCH markers — any user-authored content outside it is preserved verbatim.
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
8
|
const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
|
|
9
9
|
const END = "<!-- HUNCH:END -->";
|
|
10
10
|
export function renderHunchSection(store) {
|
|
@@ -45,20 +45,18 @@ export function renderHunchSection(store) {
|
|
|
45
45
|
lines.push(END);
|
|
46
46
|
return lines.join("\n");
|
|
47
47
|
}
|
|
48
|
-
/** Insert/replace the HUNCH section in
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
/** Insert/replace the marker-delimited HUNCH section in a markdown doc, preserving
|
|
49
|
+
* all user-authored content outside the markers. Shared by CLAUDE.md, AGENTS.md,
|
|
50
|
+
* and .github/copilot-instructions.md so every assistant gets the same grounding. */
|
|
51
|
+
export function upsertSection(file, section, fallbackTitle) {
|
|
52
52
|
let content = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
53
53
|
const iStart = content.indexOf(START);
|
|
54
54
|
const iEnd = content.indexOf(END);
|
|
55
55
|
if (iStart >= 0 && iEnd > iStart) {
|
|
56
|
-
// clean both-marker case: replace in place, preserving surrounding content
|
|
57
56
|
content = content.slice(0, iStart) + section + content.slice(iEnd + END.length);
|
|
58
57
|
}
|
|
59
58
|
else if (iStart >= 0 || iEnd >= 0) {
|
|
60
|
-
// partial/corrupt markers
|
|
61
|
-
// stray marker line, then append ONE clean section — never duplicate.
|
|
59
|
+
// partial/corrupt markers: strip stray marker lines, then append ONE clean section.
|
|
62
60
|
const body = content.split("\n").filter((l) => !l.includes(START) && !l.includes(END)).join("\n").trimEnd();
|
|
63
61
|
content = body ? `${body}\n\n${section}\n` : `${section}\n`;
|
|
64
62
|
}
|
|
@@ -66,11 +64,16 @@ export function updateClaudeMd(root, store) {
|
|
|
66
64
|
content = `${content.trimEnd()}\n\n${section}\n`;
|
|
67
65
|
}
|
|
68
66
|
else {
|
|
69
|
-
content =
|
|
67
|
+
content = `${fallbackTitle}\n\n${section}\n`;
|
|
70
68
|
}
|
|
69
|
+
mkdirSync(dirname(file), { recursive: true }); // e.g. .github/ for copilot-instructions
|
|
71
70
|
writeFileSync(file, content);
|
|
72
71
|
return file;
|
|
73
72
|
}
|
|
73
|
+
/** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
|
|
74
|
+
export function updateClaudeMd(root, store) {
|
|
75
|
+
return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store), `# ${root.split("/").pop()}`);
|
|
76
|
+
}
|
|
74
77
|
function sev(s) {
|
|
75
78
|
return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
|
|
76
79
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-assistant compatibility (DESIGN §7, extended). The Hunch MCP server is
|
|
3
|
+
* client-agnostic — any MCP-capable assistant can call the `hunch_*` tools. The
|
|
4
|
+
* only per-tool difference is HOW each one is told to launch the server and where
|
|
5
|
+
* its ambient grounding lives. This module scaffolds those surfaces for the major
|
|
6
|
+
* assistants so the same `.hunch/` graph powers all of them:
|
|
7
|
+
*
|
|
8
|
+
* Assistant | MCP config | root key | grounding file
|
|
9
|
+
* ------------|-------------------------|----------------|---------------------------------
|
|
10
|
+
* Claude Code | .mcp.json | mcpServers | CLAUDE.md (scaffold.ts)
|
|
11
|
+
* Cursor | .cursor/mcp.json | mcpServers | .cursor/rules/hunch.mdc
|
|
12
|
+
* VS Code | .vscode/mcp.json | servers (+type)| .github/copilot-instructions.md
|
|
13
|
+
* Codex CLI | .codex/config.toml | [mcp_servers.*]| AGENTS.md
|
|
14
|
+
* (any other) | — | — | AGENTS.md (cross-tool standard)
|
|
15
|
+
*
|
|
16
|
+
* Every writer MERGES into existing files (preserving other servers / user prose)
|
|
17
|
+
* and is idempotent, so re-running `hunch init` is safe.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { join, dirname } from "node:path";
|
|
21
|
+
import { renderHunchSection, upsertSection } from "./claudemd.js";
|
|
22
|
+
/** Strip // line and block comments + trailing commas (JSONC → JSON). String-aware
|
|
23
|
+
* (double-quoted, with escapes) so a // inside a value isn't mangled. VS Code's
|
|
24
|
+
* .vscode/mcp.json is JSONC, so we must tolerate comments. */
|
|
25
|
+
function stripJsonc(s) {
|
|
26
|
+
let out = "";
|
|
27
|
+
let inStr = false;
|
|
28
|
+
for (let i = 0; i < s.length; i++) {
|
|
29
|
+
const c = s[i];
|
|
30
|
+
const n = s[i + 1];
|
|
31
|
+
if (inStr) {
|
|
32
|
+
out += c;
|
|
33
|
+
if (c === "\\") {
|
|
34
|
+
out += n ?? "";
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === '"')
|
|
39
|
+
inStr = false;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (c === '"') {
|
|
43
|
+
inStr = true;
|
|
44
|
+
out += c;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (c === "/" && n === "/") {
|
|
48
|
+
while (i < s.length && s[i] !== "\n")
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === "/" && n === "*") {
|
|
53
|
+
i += 2;
|
|
54
|
+
while (i < s.length && !(s[i] === "*" && s[i + 1] === "/"))
|
|
55
|
+
i++;
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
out += c;
|
|
60
|
+
}
|
|
61
|
+
return dropTrailingCommas(out);
|
|
62
|
+
}
|
|
63
|
+
/** Remove trailing commas (`,` before `}`/`]`) — string-aware, so a comma inside
|
|
64
|
+
* a string value (e.g. "a,]") is never touched. A blanket regex would corrupt it
|
|
65
|
+
* (the same trap test/migrate.test.ts guards against). Runs on comment-free text,
|
|
66
|
+
* so lookahead need only skip whitespace. */
|
|
67
|
+
function dropTrailingCommas(s) {
|
|
68
|
+
let out = "";
|
|
69
|
+
let inStr = false;
|
|
70
|
+
let esc = false;
|
|
71
|
+
for (let i = 0; i < s.length; i++) {
|
|
72
|
+
const c = s[i];
|
|
73
|
+
if (inStr) {
|
|
74
|
+
out += c;
|
|
75
|
+
if (esc)
|
|
76
|
+
esc = false;
|
|
77
|
+
else if (c === "\\")
|
|
78
|
+
esc = true;
|
|
79
|
+
else if (c === '"')
|
|
80
|
+
inStr = false;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (c === '"') {
|
|
84
|
+
inStr = true;
|
|
85
|
+
out += c;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (c === ",") {
|
|
89
|
+
let j = i + 1;
|
|
90
|
+
while (j < s.length && /\s/.test(s[j]))
|
|
91
|
+
j++;
|
|
92
|
+
if (s[j] === "}" || s[j] === "]")
|
|
93
|
+
continue; // trailing comma → drop
|
|
94
|
+
}
|
|
95
|
+
out += c;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** Read a JSON/JSONC object. Returns {} only for an ABSENT or empty file. A
|
|
100
|
+
* non-empty file we cannot parse THROWS — overwriting it would silently wipe the
|
|
101
|
+
* user's other MCP servers. */
|
|
102
|
+
function readJsonObj(file) {
|
|
103
|
+
if (!existsSync(file))
|
|
104
|
+
return {};
|
|
105
|
+
const raw = readFileSync(file, "utf8");
|
|
106
|
+
if (!raw.trim())
|
|
107
|
+
return {};
|
|
108
|
+
try {
|
|
109
|
+
const v = JSON.parse(stripJsonc(raw));
|
|
110
|
+
if (v && typeof v === "object" && !Array.isArray(v))
|
|
111
|
+
return v;
|
|
112
|
+
throw new Error("not a JSON object");
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
throw new Error(`refusing to overwrite ${file}: could not parse it (${e.message}). Fix or remove it, then re-run.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Render a string as a TOML value: a literal '…' when safe (no escaping needed —
|
|
119
|
+
* ideal for Windows backslash paths), else a basic "…" with escapes. */
|
|
120
|
+
function tomlStr(s) {
|
|
121
|
+
// TOML literal '…' needs no escaping (ideal for Windows backslash paths) but
|
|
122
|
+
// can't contain a quote or newline; otherwise a basic "…" with escapes.
|
|
123
|
+
if (!/['\r\n]/.test(s))
|
|
124
|
+
return `'${s}'`;
|
|
125
|
+
const esc = s
|
|
126
|
+
.replace(/\\/g, "\\\\")
|
|
127
|
+
.replace(/"/g, '\\"')
|
|
128
|
+
.replace(/\n/g, "\\n")
|
|
129
|
+
.replace(/\r/g, "\\r")
|
|
130
|
+
.replace(/\t/g, "\\t");
|
|
131
|
+
return `"${esc}"`;
|
|
132
|
+
}
|
|
133
|
+
function writeJson(file, obj) {
|
|
134
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
135
|
+
writeFileSync(file, JSON.stringify(obj, null, 2) + "\n");
|
|
136
|
+
return file;
|
|
137
|
+
}
|
|
138
|
+
/** Cursor: .cursor/mcp.json — same `mcpServers` shape as Claude Desktop/Code. */
|
|
139
|
+
export function writeCursorMcp(root, inv) {
|
|
140
|
+
const file = join(root, ".cursor", "mcp.json");
|
|
141
|
+
const json = readJsonObj(file);
|
|
142
|
+
json.mcpServers = json.mcpServers ?? {};
|
|
143
|
+
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
144
|
+
return writeJson(file, json);
|
|
145
|
+
}
|
|
146
|
+
/** VS Code (Copilot agent mode): .vscode/mcp.json — root key is `servers`, and
|
|
147
|
+
* each stdio entry carries an explicit `type: "stdio"` (VS Code's schema). */
|
|
148
|
+
export function writeVscodeMcp(root, inv) {
|
|
149
|
+
const file = join(root, ".vscode", "mcp.json");
|
|
150
|
+
const json = readJsonObj(file);
|
|
151
|
+
json.servers = json.servers ?? {};
|
|
152
|
+
json.servers.hunch = { type: "stdio", command: inv.command, args: [...inv.args, "mcp"] };
|
|
153
|
+
return writeJson(file, json);
|
|
154
|
+
}
|
|
155
|
+
const TOML_START = "# >>> hunch mcp (managed) >>>";
|
|
156
|
+
const TOML_END = "# <<< hunch mcp <<<";
|
|
157
|
+
/** Codex CLI: .codex/config.toml — `[mcp_servers.hunch]` stdio entry. We own only
|
|
158
|
+
* a marker-delimited block; any other TOML the user has is preserved. Paths use
|
|
159
|
+
* TOML single-quote LITERAL strings so Windows backslashes need no escaping. */
|
|
160
|
+
export function writeCodexConfig(root, inv) {
|
|
161
|
+
const file = join(root, ".codex", "config.toml");
|
|
162
|
+
const argsToml = [...inv.args, "mcp"].map(tomlStr).join(", ");
|
|
163
|
+
const block = [
|
|
164
|
+
TOML_START,
|
|
165
|
+
"[mcp_servers.hunch]",
|
|
166
|
+
`command = ${tomlStr(inv.command)}`,
|
|
167
|
+
`args = [${argsToml}]`,
|
|
168
|
+
TOML_END,
|
|
169
|
+
].join("\n");
|
|
170
|
+
// Strip any prior managed block first, so `base` is the user's own TOML.
|
|
171
|
+
const content = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
172
|
+
const i = content.indexOf(TOML_START);
|
|
173
|
+
const j = content.indexOf(TOML_END);
|
|
174
|
+
let base;
|
|
175
|
+
if (i >= 0 && j > i)
|
|
176
|
+
base = content.slice(0, i) + content.slice(j + TOML_END.length);
|
|
177
|
+
else if (i >= 0 || j >= 0)
|
|
178
|
+
base = content.split("\n").filter((l) => !l.includes(TOML_START) && !l.includes(TOML_END)).join("\n");
|
|
179
|
+
else
|
|
180
|
+
base = content;
|
|
181
|
+
// A user-authored [mcp_servers.hunch] outside our block would make TWO tables of
|
|
182
|
+
// the same name → TOML duplicate-table error. Refuse rather than corrupt it.
|
|
183
|
+
if (/^\s*\[mcp_servers\.hunch\]/m.test(base)) {
|
|
184
|
+
throw new Error(`refusing to edit ${file}: it already defines [mcp_servers.hunch] outside Hunch's managed block. Remove it, then re-run.`);
|
|
185
|
+
}
|
|
186
|
+
base = base.trimEnd();
|
|
187
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
188
|
+
writeFileSync(file, base ? `${base}\n\n${block}\n` : `${block}\n`);
|
|
189
|
+
return file;
|
|
190
|
+
}
|
|
191
|
+
/** AGENTS.md — the cross-tool ambient-instruction standard (Codex and a growing
|
|
192
|
+
* set of assistants read it). Marker-delimited so user prose is preserved. */
|
|
193
|
+
export function writeAgentsMd(root, store) {
|
|
194
|
+
return upsertSection(join(root, "AGENTS.md"), renderHunchSection(store), "# AGENTS.md");
|
|
195
|
+
}
|
|
196
|
+
/** GitHub Copilot custom instructions (VS Code / github.com). Same grounding. */
|
|
197
|
+
export function writeCopilotInstructions(root, store) {
|
|
198
|
+
return upsertSection(join(root, ".github", "copilot-instructions.md"), renderHunchSection(store), "# Copilot instructions");
|
|
199
|
+
}
|
|
200
|
+
/** Cursor project rule (.mdc = frontmatter + body). `alwaysApply` keeps the Hunch
|
|
201
|
+
* grounding in context for every request. Fully managed by Hunch (overwritten). */
|
|
202
|
+
export function writeCursorRule(root, store) {
|
|
203
|
+
const file = join(root, ".cursor", "rules", "hunch.mdc");
|
|
204
|
+
const body = `---\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${renderHunchSection(store)}\n`;
|
|
205
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
206
|
+
writeFileSync(file, body);
|
|
207
|
+
return file;
|
|
208
|
+
}
|
|
209
|
+
/** Scaffold MCP config + grounding for all supported assistants. Returns a
|
|
210
|
+
* per-assistant summary for `hunch init` to print. Each assistant is isolated:
|
|
211
|
+
* a writer that refuses to clobber a malformed file degrades to a warning rather
|
|
212
|
+
* than aborting the rest. Claude Code is handled separately by scaffold.ts. */
|
|
213
|
+
export function scaffoldProviders(root, inv, store) {
|
|
214
|
+
const tasks = [
|
|
215
|
+
["Cursor", () => [writeCursorMcp(root, inv), writeCursorRule(root, store)]],
|
|
216
|
+
["VS Code (Copilot)", () => [writeVscodeMcp(root, inv), writeCopilotInstructions(root, store)]],
|
|
217
|
+
["Codex CLI", () => [writeCodexConfig(root, inv)]],
|
|
218
|
+
["Any (AGENTS.md)", () => [writeAgentsMd(root, store)]],
|
|
219
|
+
];
|
|
220
|
+
return tasks.map(([assistant, run]) => {
|
|
221
|
+
try {
|
|
222
|
+
return { assistant, files: run() };
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
return { assistant, files: [], error: e.message };
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=providers.js.map
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* - .claude/commands/* → user-triggered slash commands for the §5 workflows
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
8
|
/** Merge a `hunch` server entry into .mcp.json, preserving other servers. */
|
|
9
9
|
export function writeMcpJson(root, inv) {
|
|
10
10
|
const file = join(root, ".mcp.json");
|
|
@@ -55,6 +55,62 @@ then produce a **fragility report with evidence**: the specific files/functions,
|
|
|
55
55
|
the bug history behind them, their churn and fan-in, and any missing guards.
|
|
56
56
|
Avoid generic advice — every claim must cite a Hunch record or metric.
|
|
57
57
|
`;
|
|
58
|
+
/** A settings.json hook entry is Hunch's if any of its commands ends with the
|
|
59
|
+
* Hunch CLI entry + the `hook` subcommand (e.g. `…/index.js hook`). Matching the
|
|
60
|
+
* command TAIL — not the absolute path — makes re-init idempotent AND survives a
|
|
61
|
+
* repo-folder rename (the path before index.js changes; the tail does not). The
|
|
62
|
+
* leading path separator (`/` or `\`) requires `index` to be a full path segment,
|
|
63
|
+
* so a foreign tool's `…/myindex.js hook` isn't mistaken for ours and clobbered. */
|
|
64
|
+
function isHunchHook(entry) {
|
|
65
|
+
return !!entry.hooks?.some((h) => typeof h.command === "string" && /[\\/]index\.(js|ts)"?\s+hook\s*$/.test(h.command));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Install the Claude Code AGENT hooks into `.claude/settings.json` so the agent
|
|
69
|
+
* is grounded in Hunch automatically (not by remembering to call the tools):
|
|
70
|
+
* - PreToolUse (Edit|Write|MultiEdit) → inject the relevant Hunch slice before
|
|
71
|
+
* an edit, and (at strict firmness) deny edits that hit a blocking invariant.
|
|
72
|
+
* - UserPromptSubmit → remind the agent to consult Hunch.
|
|
73
|
+
* Both invoke `hunch hook`, which reads the firmness level from .hunch/config.json
|
|
74
|
+
* at run time — so changing firmness needs no settings.json edit. We own only our
|
|
75
|
+
* entries (matched by isHunchHook): other hooks and settings are preserved, and a
|
|
76
|
+
* non-empty file we cannot parse THROWS rather than clobbering the user's config.
|
|
77
|
+
*/
|
|
78
|
+
export function installClaudeHooks(root, hookCmd) {
|
|
79
|
+
const file = join(root, ".claude", "settings.json");
|
|
80
|
+
const existed = existsSync(file);
|
|
81
|
+
let json = {};
|
|
82
|
+
let before = "";
|
|
83
|
+
if (existed) {
|
|
84
|
+
before = readFileSync(file, "utf8");
|
|
85
|
+
if (before.trim()) {
|
|
86
|
+
try {
|
|
87
|
+
const v = JSON.parse(before);
|
|
88
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
89
|
+
throw new Error("not a JSON object");
|
|
90
|
+
json = v;
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
throw new Error(`refusing to overwrite ${file}: could not parse it (${e.message}). Fix or remove it, then re-run.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
json.hooks = json.hooks ?? {};
|
|
98
|
+
const keep = (arr) => (Array.isArray(arr) ? arr.filter((e) => !isHunchHook(e)) : []);
|
|
99
|
+
json.hooks.PreToolUse = [
|
|
100
|
+
...keep(json.hooks.PreToolUse),
|
|
101
|
+
{ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: hookCmd }] },
|
|
102
|
+
];
|
|
103
|
+
json.hooks.UserPromptSubmit = [
|
|
104
|
+
...keep(json.hooks.UserPromptSubmit),
|
|
105
|
+
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
106
|
+
];
|
|
107
|
+
const next = JSON.stringify(json, null, 2) + "\n";
|
|
108
|
+
if (existed && before === next)
|
|
109
|
+
return { path: file, action: "unchanged" };
|
|
110
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
111
|
+
writeFileSync(file, next);
|
|
112
|
+
return { path: file, action: existed ? "updated" : "created" };
|
|
113
|
+
}
|
|
58
114
|
export function writeSlashCommands(root) {
|
|
59
115
|
const dir = join(root, ".claude", "commands");
|
|
60
116
|
mkdirSync(dir, { recursive: true });
|
package/dist/mcp/server.js
CHANGED
|
@@ -38,6 +38,12 @@ function resolveSymbols(store, target) {
|
|
|
38
38
|
return byName;
|
|
39
39
|
return syms.filter((s) => s.file === target || s.file.endsWith(target));
|
|
40
40
|
}
|
|
41
|
+
/** Resolve a target to canonical indexed file path(s) (for file-granular blast
|
|
42
|
+
* radius). Falls back to the literal target so direct-scope checks still run. */
|
|
43
|
+
function resolveFiles(store, target) {
|
|
44
|
+
const files = new Set(resolveSymbols(store, target).map((s) => s.file));
|
|
45
|
+
return files.size ? [...files] : [target];
|
|
46
|
+
}
|
|
41
47
|
export function buildServer(root) {
|
|
42
48
|
const store = new HunchStore(hunchPaths(root));
|
|
43
49
|
// Ensure the SQLite index reflects the JSON source of truth on startup.
|
|
@@ -143,6 +149,35 @@ export function buildServer(root) {
|
|
|
143
149
|
const lines = deps.slice(0, DEP_CAP).map((d) => ` • [depth ${d.depth}] ${d.via} (${d.id})`);
|
|
144
150
|
return ok(`Blast radius of "${symbol}" — ${deps.length} dependent(s):\n${lines.join("\n")}${more(deps.length, DEP_CAP, "closest shown first")}`);
|
|
145
151
|
});
|
|
152
|
+
// -- hunch_blast_radius (dependents + near-violations) --------------------
|
|
153
|
+
server.registerTool("hunch_blast_radius", {
|
|
154
|
+
title: "Blast radius + near-violations for a file",
|
|
155
|
+
description: "Given a file you're about to change, return its dependency blast radius (files whose code depends on it) AND any invariants reached THROUGH that radius — 'near-violations' you could break indirectly without touching their own scope. Call before editing a widely-depended-on file. Mirrors `hunch check --blast`.",
|
|
156
|
+
inputSchema: { target: z.string().describe("A file path (e.g. src/auth/jwt.ts) or symbol.") },
|
|
157
|
+
}, async ({ target }) => {
|
|
158
|
+
const parts = [];
|
|
159
|
+
for (const file of resolveFiles(store, target)) {
|
|
160
|
+
const blast = store.blastRadiusFiles(file);
|
|
161
|
+
const directIds = new Set(store.checkConstraints(file).map((c) => c.id));
|
|
162
|
+
const near = new Map();
|
|
163
|
+
for (const b of blast) {
|
|
164
|
+
for (const c of store.checkConstraints(b.file)) {
|
|
165
|
+
if (directIds.has(c.id) || near.has(c.id))
|
|
166
|
+
continue;
|
|
167
|
+
near.set(c.id, { c, via: `${b.file} (${b.via}, depth ${b.depth})` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const blastBody = blast.length
|
|
171
|
+
? `:\n${blast.slice(0, DEP_CAP).map((b) => ` • [depth ${b.depth}] ${b.file} (via ${b.via})`).join("\n")}${more(blast.length, DEP_CAP, "closest first")}`
|
|
172
|
+
: "";
|
|
173
|
+
const nearArr = [...near.values()];
|
|
174
|
+
const nearBody = nearArr.length
|
|
175
|
+
? `\n NEAR-VIOLATIONS (invariants reachable via this radius — review before editing):\n${nearArr.map((n) => ` ⚠ ${n.c.id} [${n.c.severity}] ${n.c.statement}\n via ${n.via}`).join("\n")}`
|
|
176
|
+
: "\n No invariants in the blast radius.";
|
|
177
|
+
parts.push(`${file} → ${blast.length} dependent file(s)${blastBody}${nearBody}`);
|
|
178
|
+
}
|
|
179
|
+
return ok(`Blast radius for "${target}":\n\n${parts.join("\n\n")}`);
|
|
180
|
+
});
|
|
146
181
|
// -- hunch_context (surgical retrieval) -----------------------------------
|
|
147
182
|
server.registerTool("hunch_context", {
|
|
148
183
|
title: "Assemble the minimal relevant Hunch slice for a task",
|
|
@@ -132,9 +132,55 @@ const BUG_TOOL = {
|
|
|
132
132
|
},
|
|
133
133
|
};
|
|
134
134
|
// --------------------------------------------------------------------------
|
|
135
|
+
// Base for headless-CLI SUBSCRIPTION providers. Each one drives a coding-assistant
|
|
136
|
+
// CLI billed to the user's own subscription (never a pay-per-token API key — see
|
|
137
|
+
// dec_5a7c0733f7). The prompt always goes over STDIN (never argv — keeps untrusted
|
|
138
|
+
// diff content out of any shell pexecIn uses on Windows), and the CLI's text output
|
|
139
|
+
// is handed to the SAME mappers, so the rest of the system is provider-agnostic.
|
|
140
|
+
// --------------------------------------------------------------------------
|
|
141
|
+
class CliSynthProvider {
|
|
142
|
+
/** Run a CLI with the prompt on stdin, stripping API-key env vars so the tool
|
|
143
|
+
* falls through to its SUBSCRIPTION credentials. Shared by codex/cursor. */
|
|
144
|
+
async runCli(bin, args, stripEnv, prompt, timeoutMs = 120_000) {
|
|
145
|
+
const env = { ...process.env };
|
|
146
|
+
for (const k of stripEnv)
|
|
147
|
+
delete env[k];
|
|
148
|
+
const { stdout } = await pexecIn(bin, args, {
|
|
149
|
+
input: prompt,
|
|
150
|
+
env,
|
|
151
|
+
cwd: tmpdir(),
|
|
152
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
153
|
+
timeout: timeoutMs,
|
|
154
|
+
});
|
|
155
|
+
return stdout;
|
|
156
|
+
}
|
|
157
|
+
async draftDecision(input) {
|
|
158
|
+
const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`);
|
|
159
|
+
const draft = decisionDraftFromText(text, input.subject);
|
|
160
|
+
// No usable LLM JSON (truncation, refusal, prose-only, or a CLI whose output
|
|
161
|
+
// shape we misread) → THROW so the safe wrapper falls back to the deterministic
|
|
162
|
+
// provider, whose draft is honestly labeled ("inferred", low confidence).
|
|
163
|
+
if (!draft)
|
|
164
|
+
throw new Error(`${this.name}: no usable decision JSON in output`);
|
|
165
|
+
// For a LARGE diff the model only saw the structured summary + a sample — haircut
|
|
166
|
+
// the confidence and tag the source so provenance stays honest.
|
|
167
|
+
if (input.diff.length > LARGE_DIFF_CHARS) {
|
|
168
|
+
return { ...draft, confidence: Math.min(draft.confidence, 0.5), source: `${draft.source}+summary` };
|
|
169
|
+
}
|
|
170
|
+
return draft;
|
|
171
|
+
}
|
|
172
|
+
async draftBug(input) {
|
|
173
|
+
const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`);
|
|
174
|
+
const draft = bugDraftFromText(text, input.test, input.message);
|
|
175
|
+
if (!draft)
|
|
176
|
+
throw new Error(`${this.name}: no usable bug JSON in output`);
|
|
177
|
+
return draft;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// --------------------------------------------------------------------------
|
|
135
181
|
// Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
|
|
136
182
|
// --------------------------------------------------------------------------
|
|
137
|
-
class ClaudeCliProvider {
|
|
183
|
+
class ClaudeCliProvider extends CliSynthProvider {
|
|
138
184
|
name = "claude-cli";
|
|
139
185
|
// Default to the `haiku` alias (cheap/fast, and survives model retirements)
|
|
140
186
|
// rather than a pinned dated id; override with HUNCH_SYNTH_MODEL if needed.
|
|
@@ -190,31 +236,54 @@ class ClaudeCliProvider {
|
|
|
190
236
|
}
|
|
191
237
|
return envelope.result ?? stdout;
|
|
192
238
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (input.diff.length > LARGE_DIFF_CHARS) {
|
|
208
|
-
return { ...draft, confidence: Math.min(draft.confidence, 0.5), source: `${draft.source}+summary` };
|
|
239
|
+
}
|
|
240
|
+
// --------------------------------------------------------------------------
|
|
241
|
+
// Provider B1: OpenAI Codex CLI (`codex exec`) — billed to the ChatGPT subscription
|
|
242
|
+
// --------------------------------------------------------------------------
|
|
243
|
+
class CodexCliProvider extends CliSynthProvider {
|
|
244
|
+
name = "codex-cli";
|
|
245
|
+
model = process.env.HUNCH_CODEX_MODEL; // omit → codex uses its configured default
|
|
246
|
+
async available() {
|
|
247
|
+
try {
|
|
248
|
+
await pexecIn("codex", ["--version"], { timeout: 8000 });
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return false;
|
|
209
253
|
}
|
|
210
|
-
return draft;
|
|
211
254
|
}
|
|
212
|
-
async
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
255
|
+
async run(prompt) {
|
|
256
|
+
// `codex exec --json -` reads the prompt from STDIN (the `-`), emits JSONL
|
|
257
|
+
// events. Strip OPENAI_API_KEY so it uses ChatGPT (subscription) auth, not the
|
|
258
|
+
// pay-per-token API — consistent with the subscription-only rule.
|
|
259
|
+
const args = ["exec", "--json", ...(this.model ? ["-m", this.model] : []), "-"];
|
|
260
|
+
const out = await this.runCli("codex", args, ["OPENAI_API_KEY"], prompt);
|
|
261
|
+
return extractCodexText(out);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// --------------------------------------------------------------------------
|
|
265
|
+
// Provider B2: Cursor Agent CLI (`cursor-agent -p`) — billed to the Cursor subscription
|
|
266
|
+
// --------------------------------------------------------------------------
|
|
267
|
+
class CursorCliProvider extends CliSynthProvider {
|
|
268
|
+
name = "cursor-agent";
|
|
269
|
+
model = process.env.HUNCH_CURSOR_MODEL;
|
|
270
|
+
async available() {
|
|
271
|
+
try {
|
|
272
|
+
await pexecIn("cursor-agent", ["--version"], { timeout: 8000 });
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async run(prompt) {
|
|
280
|
+
// `-p --output-format text` → final answer as plain text (no event stream to
|
|
281
|
+
// parse). `--trust` so it runs non-interactively. Prompt over stdin. Cursor's
|
|
282
|
+
// CLI uses the user's Cursor login (subscription) — no API key to strip.
|
|
283
|
+
const args = ["-p", "--output-format", "text", "--trust", ...(this.model ? ["-m", this.model] : [])];
|
|
284
|
+
// Shorter timeout than the others: cursor-agent -p is reported to hang in some
|
|
285
|
+
// headless setups; cap the stall before degrading to the deterministic provider.
|
|
286
|
+
return this.runCli("cursor-agent", args, [], prompt, 45_000);
|
|
218
287
|
}
|
|
219
288
|
}
|
|
220
289
|
// --------------------------------------------------------------------------
|
|
@@ -269,23 +338,71 @@ export class DeterministicProvider {
|
|
|
269
338
|
};
|
|
270
339
|
}
|
|
271
340
|
}
|
|
272
|
-
|
|
341
|
+
/** Extract the final assistant message from `codex exec --json` output (newline-
|
|
342
|
+
* delimited JSON events). Codex tags assistant turns as `item.type ==="agent_message"`,
|
|
343
|
+
* but it ALSO emits `item.text` for reasoning and may append trailing events — so we
|
|
344
|
+
* prefer the last AGENT message and only fall back to the last any-text when none is
|
|
345
|
+
* tagged. If nothing parses, hand the raw output to the mapper (→ it finds the JSON
|
|
346
|
+
* draft or throws → deterministic fallback). Tolerant by design: drift degrades, never crashes. */
|
|
347
|
+
export function extractCodexText(out) {
|
|
348
|
+
const texts = [];
|
|
349
|
+
const agentTexts = [];
|
|
350
|
+
for (const line of out.split(/\r?\n/)) {
|
|
351
|
+
const t = line.trim();
|
|
352
|
+
if (!t.startsWith("{"))
|
|
353
|
+
continue;
|
|
354
|
+
try {
|
|
355
|
+
const o = JSON.parse(t);
|
|
356
|
+
const item = o.item;
|
|
357
|
+
const cand = (item?.text ?? o.text ?? o.message);
|
|
358
|
+
if (typeof cand === "string" && cand.trim()) {
|
|
359
|
+
texts.push(cand);
|
|
360
|
+
const ty = (item?.type ?? o.type);
|
|
361
|
+
if (typeof ty === "string" && /agent|assistant|message\b/.test(ty) && !/reason/.test(ty))
|
|
362
|
+
agentTexts.push(cand);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
/* not a JSON event line — skip */
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (agentTexts.length)
|
|
370
|
+
return agentTexts[agentTexts.length - 1];
|
|
371
|
+
return texts.length ? texts[texts.length - 1] : out;
|
|
372
|
+
}
|
|
373
|
+
// Priority order: try each subscription CLI, then the always-available heuristic.
|
|
374
|
+
// HUNCH_SYNTH_PROVIDER forces one by name (claude-cli / codex-cli / cursor-agent /
|
|
375
|
+
// deterministic).
|
|
376
|
+
const PROVIDERS = [
|
|
377
|
+
new ClaudeCliProvider(),
|
|
378
|
+
new CodexCliProvider(),
|
|
379
|
+
new CursorCliProvider(),
|
|
380
|
+
new DeterministicProvider(),
|
|
381
|
+
];
|
|
382
|
+
// Availability rarely changes within a process (a CLI doesn't get installed mid-run),
|
|
383
|
+
// and selectProvider() runs on every sync/recordFailure — so memoize each probe.
|
|
384
|
+
// Especially matters in the long-lived MCP server and on machines with NO assistant
|
|
385
|
+
// CLI, where an uncached pass spawns one failing `--version` per provider every time.
|
|
386
|
+
const availCache = new Map();
|
|
387
|
+
function isAvailable(p) {
|
|
388
|
+
let v = availCache.get(p.name);
|
|
389
|
+
if (!v) {
|
|
390
|
+
v = p.available().catch(() => false);
|
|
391
|
+
availCache.set(p.name, v);
|
|
392
|
+
}
|
|
393
|
+
return v;
|
|
394
|
+
}
|
|
273
395
|
/** Choose the first available provider, honoring HUNCH_SYNTH_PROVIDER override. */
|
|
274
396
|
export async function selectProvider() {
|
|
275
397
|
const forced = process.env.HUNCH_SYNTH_PROVIDER;
|
|
276
398
|
if (forced) {
|
|
277
399
|
const p = PROVIDERS.find((x) => x.name === forced);
|
|
278
|
-
if (p && (await p
|
|
400
|
+
if (p && (await isAvailable(p)))
|
|
279
401
|
return p;
|
|
280
402
|
}
|
|
281
403
|
for (const p of PROVIDERS) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return p;
|
|
285
|
-
}
|
|
286
|
-
catch {
|
|
287
|
-
/* try next */
|
|
288
|
-
}
|
|
404
|
+
if (await isAvailable(p))
|
|
405
|
+
return p;
|
|
289
406
|
}
|
|
290
407
|
return new DeterministicProvider();
|
|
291
408
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"license": "MIT",
|
|
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.",
|