@davesheffer/hunch 0.34.0 → 0.35.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 +31 -70
- package/dist/cli/index.js +81 -10
- package/dist/core/constraintmatch.js +28 -0
- package/dist/core/correction.js +1 -0
- package/dist/core/hookpolicy.js +25 -12
- package/dist/core/types.js +5 -0
- package/dist/eval/guards.js +70 -0
- package/dist/store/hunchStore.js +25 -4
- package/dist/synthesis/synthesize.js +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -53,11 +53,11 @@ came before. Local-first, no documentation toil, no SaaS.
|
|
|
53
53
|
└───────────────────────┘ └──────────────────────────┘ └──────────────┘
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
- **Index** (no LLM):
|
|
57
|
-
|
|
56
|
+
- **Index** (no LLM): Hunch maps your repo — how functions, files, and components
|
|
57
|
+
connect — so it can see the ripple effect of any change.
|
|
58
58
|
- **Learn**: each commit becomes a structured **Decision** (an ADR); a failing test
|
|
59
|
-
becomes a **Bug** with
|
|
60
|
-
into **Constraints** (do-not-break
|
|
59
|
+
becomes a **Bug** with its likely cause; recurring or severe bugs are promoted
|
|
60
|
+
into **Constraints** (do-not-break rules) and flag the riskiest parts of the code.
|
|
61
61
|
- **Ground**: any MCP assistant reads it through an **MCP server**, an auto-maintained
|
|
62
62
|
**`CLAUDE.md`**, and **slash commands** — every answer cites `provenance`
|
|
63
63
|
(source + confidence + evidence), so nothing is a blind assertion.
|
|
@@ -78,7 +78,7 @@ combination is the moat:
|
|
|
78
78
|
| **Lifetime** | the session; often auto-expiring | the **lifetime of the codebase** — non-destructive supersede/veto keeps the *why-it-changed* trail |
|
|
79
79
|
| **Clients** | one vendor's agent | **client-agnostic** — one `.hunch/` graph serves Claude Code, Cursor, Copilot & Windsurf via MCP |
|
|
80
80
|
| **What's stored** | opaque extracted "facts" | **structured ADRs** — decisions with rejected-alternatives, bug lineage, and invariants |
|
|
81
|
-
| **Enforcement** | advisory / just-in-time hints | **fail-closed
|
|
81
|
+
| **Enforcement** | advisory / just-in-time hints | **fail-closed guards** — no model in the block path; a commit fails only on a rule you've vouched for |
|
|
82
82
|
| **Trust** | take it on faith | **provenance on every record** (source + confidence + evidence) and a measurable retrieval signal (`hunch eval`) |
|
|
83
83
|
|
|
84
84
|
The short version: **git tracks *what* changed; Hunch tracks *why*** — locally, durably, and under
|
|
@@ -94,7 +94,7 @@ hunch backfill --since 90d # cold start: seed decisions from recent git
|
|
|
94
94
|
hunch why src/auth/session.ts # …then ask your assistant: "why is X built this way?"
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
`hunch init` scaffolds `.hunch/`, indexes the repo, installs the git hooks
|
|
97
|
+
`hunch init` scaffolds `.hunch/`, indexes the repo, installs the git hooks,
|
|
98
98
|
writes `.mcp.json` + slash commands + an auto-maintained `CLAUDE.md`, and wires up **every
|
|
99
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**
|
|
@@ -105,14 +105,11 @@ afterward to pick up the `hunch_*` tools. Each teammate runs `hunch init` once;
|
|
|
105
105
|
> **never** a pay-per-token API key — and falls back to a deterministic heuristic if no CLI
|
|
106
106
|
> is present. Details: [Synthesis & billing](https://hunch-pi.vercel.app/docs#synthesis).
|
|
107
107
|
>
|
|
108
|
-
> **Deep Synthesis** (`backfill --deep` / `sync --deep`):
|
|
109
|
-
>
|
|
110
|
-
>
|
|
111
|
-
>
|
|
112
|
-
>
|
|
113
|
-
> rejected-alternatives before they become tripwires and down-weighting weak grounding; it only
|
|
114
|
-
> ever *lowers* confidence, never arming enforcement. Subscription-only, never on the guard path;
|
|
115
|
-
> degrades to the single-provider draft when no CLI is available.
|
|
108
|
+
> **Deep Synthesis** (`backfill --deep` / `sync --deep`): gathers several independent takes on a
|
|
109
|
+
> change and reconciles them into one more-trustworthy note — trusting it more when they agree.
|
|
110
|
+
> Add `--verify` to fact-check the note against the commit and drop anything it doesn't support.
|
|
111
|
+
> It always stays *advisory* until you confirm it. Subscription-only; falls back to a single
|
|
112
|
+
> draft when only one assistant is available.
|
|
116
113
|
> On Windows, prefer `hunch init` over a global `claude mcp add`; if tools don't appear,
|
|
117
114
|
> `hunch doctor` heals it ([why](https://hunch-pi.vercel.app/docs#windows)).
|
|
118
115
|
|
|
@@ -120,7 +117,7 @@ afterward to pick up the `hunch_*` tools. Each teammate runs `hunch init` once;
|
|
|
120
117
|
[MCP & assistants](https://hunch-pi.vercel.app/docs#mcp) ·
|
|
121
118
|
[MCP tools](https://hunch-pi.vercel.app/docs#mcp-tools) ·
|
|
122
119
|
[slash commands](https://hunch-pi.vercel.app/docs#slash) ·
|
|
123
|
-
[the
|
|
120
|
+
[the full CLI reference](https://hunch-pi.vercel.app/docs#cli)
|
|
124
121
|
|
|
125
122
|
## Enforcement: memory that holds the line
|
|
126
123
|
|
|
@@ -152,14 +149,14 @@ VERDICT: ⛔ BLOCK — this change breaks a recorded invariant or re-opens a kno
|
|
|
152
149
|
🐞 guards against: Double-charge on unverified session (bug_…)
|
|
153
150
|
```
|
|
154
151
|
|
|
155
|
-
|
|
156
|
-
|
|
152
|
+
No model in the loop, so it's safe as a merge gate — it blocks only on a high-confidence rule
|
|
153
|
+
you've confirmed, and warns on everything softer. → [docs](https://hunch-pi.vercel.app/docs#merge-verdict)
|
|
157
154
|
|
|
158
155
|
### Decision Guard (Veto) — re-introducing a *rejected* approach is blocked
|
|
159
156
|
|
|
160
157
|
The most expensive reversal is re-adding an approach a decision **rejected** (latency, a
|
|
161
158
|
forbidden dependency) — code that never existed, so a diff reviewer is blind to it. A
|
|
162
|
-
decision
|
|
159
|
+
decision remembers what it rejected; re-introduce that approach and Hunch blocks it with
|
|
163
160
|
the receipt of what you rejected and why. → [docs](https://hunch-pi.vercel.app/docs#veto)
|
|
164
161
|
|
|
165
162
|
### Redundancy Guard — "this already exists"
|
|
@@ -168,9 +165,8 @@ An agent works from a *local* context window, so it re-implements a helper that
|
|
|
168
165
|
lives three modules over, or re-adds a dependency the codebase already has — sprawl a
|
|
169
166
|
diff-only reviewer can't see, but Hunch's symbol graph can. Add a function or class already
|
|
170
167
|
defined elsewhere and `hunch check` / the CI guard / `hunch_merge_verdict` flag it with the
|
|
171
|
-
existing location.
|
|
172
|
-
|
|
173
|
-
refactor isn't mistaken for a duplicate). → [docs](https://hunch-pi.vercel.app/docs#redundancy)
|
|
168
|
+
existing location. **Advisory** — it never blocks, and it's tuned to stay quiet so a refactor
|
|
169
|
+
that just moves code isn't mistaken for a duplicate. → [docs](https://hunch-pi.vercel.app/docs#redundancy)
|
|
174
170
|
|
|
175
171
|
Plus the **Regression Guard** (re-adding deliberately-retired code) and the
|
|
176
172
|
**[CI Constraint Guard](https://hunch-pi.vercel.app/docs#ci)** (`hunch ci` — a PR gate that
|
|
@@ -179,20 +175,17 @@ comments the affected `con_`/`dec_` ids and fails on a blocking one).
|
|
|
179
175
|
## Working as a team
|
|
180
176
|
|
|
181
177
|
The `.hunch/` JSON is the **source of truth** — diffable, reviewable in PRs, synced for free
|
|
182
|
-
over `git push` / `pull`. `hunch init`
|
|
183
|
-
merge
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
per-machine fixups. → [docs](https://hunch-pi.vercel.app/docs#team)
|
|
178
|
+
over `git push` / `pull`. `hunch init` sets things up so concurrent edits from different
|
|
179
|
+
teammates merge cleanly instead of throwing conflict markers, and it's **OS-agnostic** —
|
|
180
|
+
Windows / macOS / Linux teammates share one memory with no per-machine fixups.
|
|
181
|
+
→ [docs](https://hunch-pi.vercel.app/docs#team)
|
|
187
182
|
|
|
188
183
|
### Branches & worktrees
|
|
189
184
|
|
|
190
|
-
Memory follows you across every branch and **git worktree**, with no per-worktree setup
|
|
191
|
-
|
|
192
|
-
a fresh `git worktree add` on any branch sees the same decisions, bugs, and invariants. Create one
|
|
185
|
+
Memory follows you across every branch and **git worktree**, with no per-worktree setup — a
|
|
186
|
+
fresh `git worktree add` on any branch sees the same decisions, bugs, and invariants. Create one
|
|
193
187
|
already wired in with **`hunch worktree <path> [-b <branch>]`**, or just run `hunch init` / `hunch
|
|
194
|
-
private` once and every worktree picks it up.
|
|
195
|
-
and concurrent overlay writes are serialized — so parallel worktrees never corrupt or lose memory.
|
|
188
|
+
private` once and every worktree picks it up. Parallel worktrees never corrupt or lose memory, and
|
|
196
189
|
`hunch doctor` confirms a worktree is sharing.
|
|
197
190
|
|
|
198
191
|
## Private memory (public repo, private context)
|
|
@@ -233,7 +226,7 @@ Drop `npx hunch test` into CI, and `hunch ci` to scaffold the PR merge gate.
|
|
|
233
226
|
|
|
234
227
|
`hunch query` uses fast keyword search out of the box. For recall on paraphrases, opt into
|
|
235
228
|
**local embeddings** (`npm i -g @huggingface/transformers && hunch embed`) — local, free, and
|
|
236
|
-
opt-in
|
|
229
|
+
opt-in, and it never drifts from your committed memory.
|
|
237
230
|
|
|
238
231
|
## VS Code
|
|
239
232
|
|
|
@@ -246,44 +239,12 @@ counter. It reads the committed `.hunch/` JSON directly; writes delegate to the
|
|
|
246
239
|
|
|
247
240
|
## Architecture
|
|
248
241
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
├─ extractors/ tree-sitter parse, git introspection, the indexer
|
|
254
|
-
├─ synthesis/ write path: subscription CLI (Claude/Codex/Cursor) or deterministic fallback
|
|
255
|
-
├─ mcp/ MCP stdio server (the hunch_* tools)
|
|
256
|
-
├─ integrations/ post-commit hook, CLAUDE.md writer, .mcp.json + slash commands, merge driver
|
|
257
|
-
└─ cli/ commander entrypoint
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
Everything lives under `.hunch/` as git-tracked JSON (the source of truth); SQLite is a
|
|
261
|
-
throwaway derived index. → [storage layout](https://hunch-pi.vercel.app/docs#storage) ·
|
|
262
|
-
[the docs](https://hunch-pi.vercel.app/docs) for the full conceptual model.
|
|
263
|
-
|
|
264
|
-
## Notable engineering decisions
|
|
265
|
-
|
|
266
|
-
- **Subscription-billed synthesis, never the API.** The write path drives your Claude
|
|
267
|
-
subscription via the `claude` CLI; `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` are
|
|
268
|
-
stripped from the child env to force subscription auth. A deterministic, no-LLM fallback
|
|
269
|
-
means the loop never hard-requires credentials.
|
|
270
|
-
- **Native `tree-sitter` (0.21.1)**, not web-tree-sitter — the prebuilt WASM grammars have an
|
|
271
|
-
incompatible ABI; the native bindings ship Node-20 prebuilds and a simpler synchronous API.
|
|
272
|
-
- **`better-sqlite3` pinned to `12.9.0`** — 12.10.x ships no Node-20 prebuild.
|
|
273
|
-
- **Atomic, durable writes.** All `.hunch/` writes go through temp-file + rename; an
|
|
274
|
-
interrupted write can't truncate the index, and `put`/`delete` refuse to rewrite a corrupt one.
|
|
275
|
-
- **OS-agnostic by construction.** Paths are canonicalized to POSIX before comparison, and
|
|
276
|
-
committed configs reference Hunch by package name, never a machine-local path.
|
|
242
|
+
Everything lives under `.hunch/` as plain git-tracked JSON — the source of truth; a fast local
|
|
243
|
+
index is built from it and is throwaway. Subscription-billed synthesis (never a pay-per-token
|
|
244
|
+
API key) with a no-LLM fallback, and atomic writes so an interrupted write can't corrupt your
|
|
245
|
+
memory. → [the docs](https://hunch-pi.vercel.app/docs) for the conceptual model.
|
|
277
246
|
|
|
278
247
|
## Develop
|
|
279
248
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
npm run typecheck # strict tsc — the gate
|
|
283
|
-
npm test # node:test suite
|
|
284
|
-
npm run build # compile to dist/ (the published artifact)
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
Hunch is pure TypeScript ESM, Node ≥ 20, licensed **Apache-2.0**. See
|
|
288
|
-
[CONTRIBUTING.md](CONTRIBUTING.md) and the full
|
|
289
|
-
[developer docs](https://hunch-pi.vercel.app/docs#develop).
|
|
249
|
+
Hunch is open source — pure TypeScript ESM, Node ≥ 20, licensed **Apache-2.0**. Contributions
|
|
250
|
+
welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the [repo](https://github.com/davesheffer/hunch).
|
package/dist/cli/index.js
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* mcp start the MCP server (Claude Code connects here)
|
|
14
14
|
* doctor environment diagnostics
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
|
|
17
17
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
18
|
-
import { join, relative, resolve, isAbsolute } from "node:path";
|
|
18
|
+
import { join, relative, dirname, basename, resolve, isAbsolute } from "node:path";
|
|
19
19
|
import { Command } from "commander";
|
|
20
20
|
import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget } from "../core/paths.js";
|
|
21
21
|
import { writeFileAtomic } from "../core/io.js";
|
|
@@ -47,6 +47,7 @@ import { formatContext } from "../core/format.js";
|
|
|
47
47
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
48
48
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
49
49
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
50
|
+
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
50
51
|
import { computeDrift } from "../core/drift.js";
|
|
51
52
|
import { compareCandidates } from "../core/compare.js";
|
|
52
53
|
import { checkConformance } from "../core/conformance.js";
|
|
@@ -514,14 +515,61 @@ program
|
|
|
514
515
|
// ---- eval (retrieval quality; measures the graph-stream lift) --------------
|
|
515
516
|
program
|
|
516
517
|
.command("eval")
|
|
517
|
-
.description("Measure retrieval
|
|
518
|
-
.
|
|
518
|
+
.description("Measure quality over a golden set: retrieval (Recall@k, MRR) or, with --guards, ENFORCEMENT (block/warn/pass precision & recall).")
|
|
519
|
+
.option("--file <path>", "golden set JSON — retrieval: [{ query, expected }]; guards: [{ name, files, expect }]")
|
|
520
|
+
.option("--guards", "score the GUARDS instead of retrieval: did the gate block the bad changes and pass the good ones?")
|
|
521
|
+
.option("--generate", "with --guards: scaffold a starter golden set from the live graph (and run it)")
|
|
519
522
|
.option("--k <n>", "top-k cutoff", "10")
|
|
520
523
|
.option("--semantic", "also blend the semantic stream (requires `hunch embed`; default is deterministic FTS + graph)")
|
|
521
524
|
.option("--kind <kind>", "restrict scoring to one record kind (e.g. runbooks) — scoped retrieval")
|
|
522
525
|
.action(async (opts) => {
|
|
523
526
|
const { store } = storeFor();
|
|
524
527
|
store.reindex(); // reflect any out-of-band JSON edits before scoring
|
|
528
|
+
// ── Guard eval: did the gate BLOCK the bad changes and PASS the good ones? Runs each
|
|
529
|
+
// case through the SAME buildCheckReport → verdict path the live guards use. ──
|
|
530
|
+
if (opts.guards) {
|
|
531
|
+
if (!opts.generate && !opts.file) {
|
|
532
|
+
store.close();
|
|
533
|
+
return fail("guard eval needs --file <golden.json> or --generate");
|
|
534
|
+
}
|
|
535
|
+
let gcases;
|
|
536
|
+
try {
|
|
537
|
+
gcases = opts.generate ? generateGuardCases(store) : loadGuardCases(readFileSync(opts.file, "utf8"));
|
|
538
|
+
}
|
|
539
|
+
catch (e) {
|
|
540
|
+
store.close();
|
|
541
|
+
return fail(`guard eval: ${e.message}`);
|
|
542
|
+
}
|
|
543
|
+
if (!gcases.length) {
|
|
544
|
+
store.close();
|
|
545
|
+
return fail("no guard cases — `--generate` needs vouched blocking constraints in the graph, or pass --file");
|
|
546
|
+
}
|
|
547
|
+
if (opts.generate && opts.file)
|
|
548
|
+
writeFileAtomic(opts.file, JSON.stringify(gcases, null, 2) + "\n");
|
|
549
|
+
const r = evalGuards(store, gcases);
|
|
550
|
+
const pct = (n, d) => (d ? `${((n / d) * 100).toFixed(0)}%` : "—");
|
|
551
|
+
console.log(`Guard eval over ${r.total} case(s) — does the gate catch bad changes and stay quiet on good ones?\n`);
|
|
552
|
+
console.log(` CAUGHT ${r.surfaced}/${r.shouldSurface} changes to guarded code surfaced (${pct(r.surfaced, r.shouldSurface)}) — nothing slips silently through`);
|
|
553
|
+
console.log(` └ of those ${r.hardBlocked} hard-block the merge, ${r.surfaced - r.hardBlocked} warn — stale/low-confidence rules warn; re-verify to harden`);
|
|
554
|
+
console.log(` FALSE-POSITIVE ${r.falsePositives}/${r.shouldPass} unrelated changes flagged (${pct(r.falsePositives, r.shouldPass)} — lower is safer to enable)`);
|
|
555
|
+
console.log(` ACCURACY ${(r.accuracy * 100).toFixed(0)}% exact verdict match\n`);
|
|
556
|
+
const wrong = r.perCase.filter((p) => !p.ok);
|
|
557
|
+
if (wrong.length) {
|
|
558
|
+
console.log(` ${wrong.length} mismatch(es) to review (relabel a generated case, or fix a guard):`);
|
|
559
|
+
for (const w of wrong.slice(0, 12))
|
|
560
|
+
console.log(` · ${w.name} — expected ${w.expect}, got ${w.got}`);
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
console.log(` ✓ every case matched its expected verdict.`);
|
|
564
|
+
}
|
|
565
|
+
store.close();
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
// ── Retrieval eval (default) ──
|
|
569
|
+
if (!opts.file) {
|
|
570
|
+
store.close();
|
|
571
|
+
return fail("retrieval eval needs --file <golden.json> (or use --guards)");
|
|
572
|
+
}
|
|
525
573
|
let cases;
|
|
526
574
|
try {
|
|
527
575
|
cases = loadGoldenSet(readFileSync(opts.file, "utf8"));
|
|
@@ -667,7 +715,7 @@ program
|
|
|
667
715
|
id, type: "correctness", statement: it.text, scope: [it.file],
|
|
668
716
|
// Advisory by default — an inline rule never auto-blocks a build; raise severity
|
|
669
717
|
// deliberately if you want enforcement. Keeps day-one zero false-positive rage.
|
|
670
|
-
severity: "warning", enforcement: "advisory_v1",
|
|
718
|
+
severity: "warning", enforcement: "advisory_v1", match: null,
|
|
671
719
|
rationale: `Captured from an inline hunch-rule comment (${it.file}:${it.line}).`,
|
|
672
720
|
source_decision: null, violations: [], status: "active",
|
|
673
721
|
valid_from: prev?.valid_from ?? now, valid_to: null,
|
|
@@ -880,6 +928,7 @@ program
|
|
|
880
928
|
.option("--rationale <text>", "why it must hold", "")
|
|
881
929
|
.option("--source-decision <id>", "decision id this derives from")
|
|
882
930
|
.option("--enforcement <e>", "advisory_v1 | ci | manual", "advisory_v1")
|
|
931
|
+
.option("--match <regex>", "content matcher: block only when an ADDED line matches this regex (precise + immune to staleness, vs scope-touch)")
|
|
883
932
|
.action((statement, opts) => {
|
|
884
933
|
const SEV = ["advisory", "warning", "blocking"];
|
|
885
934
|
if (!SEV.includes(opts.severity))
|
|
@@ -894,6 +943,7 @@ program
|
|
|
894
943
|
scope,
|
|
895
944
|
severity: opts.severity,
|
|
896
945
|
enforcement: opts.enforcement,
|
|
946
|
+
match: opts.match ?? null,
|
|
897
947
|
rationale: opts.rationale,
|
|
898
948
|
source_decision: opts.sourceDecision ?? null,
|
|
899
949
|
violations: [],
|
|
@@ -1278,16 +1328,18 @@ program
|
|
|
1278
1328
|
// index is good enough for grounding.
|
|
1279
1329
|
if (firmness === "strict") {
|
|
1280
1330
|
store.reindex();
|
|
1281
|
-
|
|
1331
|
+
// The lines this edit would ADD — so a content-matched invariant denies only
|
|
1332
|
+
// when the edit actually trips it (not on every edit in scope), and the Veto
|
|
1333
|
+
// Guard can test the proposed text. Covers Edit/Write/MultiEdit.
|
|
1334
|
+
const proposedLines = proposedEditLines(evt.tool_input);
|
|
1335
|
+
const deny = blockingInScope(store, target, proposedLines);
|
|
1282
1336
|
if (deny) {
|
|
1283
1337
|
emitDeny(deny.reason);
|
|
1284
1338
|
return;
|
|
1285
1339
|
}
|
|
1286
1340
|
// Veto Guard (live): the proposed edit text re-introduces an approach an
|
|
1287
|
-
// in-force decision REJECTED.
|
|
1288
|
-
// MultiEdit (edits[].new_string). The agent self-corrects before staging;
|
|
1341
|
+
// in-force decision REJECTED. The agent self-corrects before staging;
|
|
1289
1342
|
// only human-confirmed tripwires deny.
|
|
1290
|
-
const proposedLines = proposedEditLines(evt.tool_input);
|
|
1291
1343
|
const vetoDeny = proposedLines.length ? vetoInScope(store, target, proposedLines) : null;
|
|
1292
1344
|
if (vetoDeny) {
|
|
1293
1345
|
emitDeny(vetoDeny.reason);
|
|
@@ -1650,8 +1702,27 @@ function readStdin() {
|
|
|
1650
1702
|
}
|
|
1651
1703
|
/** Absolute edit path → repo-relative, forward-slash (constraint scopes are
|
|
1652
1704
|
* forward-slash globs even on Windows). */
|
|
1705
|
+
/** realpath a path even if it doesn't exist yet (a new file an agent is about to
|
|
1706
|
+
* Write): resolve the longest existing ancestor, then re-append the missing tail.
|
|
1707
|
+
* Idempotent on already-resolved paths. */
|
|
1708
|
+
function realpathNorm(p) {
|
|
1709
|
+
try {
|
|
1710
|
+
return realpathSync.native(p);
|
|
1711
|
+
}
|
|
1712
|
+
catch {
|
|
1713
|
+
const parent = dirname(p);
|
|
1714
|
+
if (parent === p)
|
|
1715
|
+
return p; // hit the root; nothing more to resolve
|
|
1716
|
+
return join(realpathNorm(parent), basename(p));
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
/** Repo-relative POSIX path. BOTH ends are realpath-normalized first: on macOS
|
|
1720
|
+
* `process.cwd()` (hence findRoot) resolves /var→/private/var, but a hook event's
|
|
1721
|
+
* file_path arrives UN-resolved — so a naive relative() yields a bogus "../" path
|
|
1722
|
+
* under any symlinked root (/var, /tmp, symlinked $HOME) and the caller treats the
|
|
1723
|
+
* file as outside the repo, silently dropping all context (dec_e0a36efbf5). */
|
|
1653
1724
|
function toRepoRel(root, abs) {
|
|
1654
|
-
return relative(root, abs).split("\\").join("/");
|
|
1725
|
+
return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
|
|
1655
1726
|
}
|
|
1656
1727
|
function emitContext(event, text) {
|
|
1657
1728
|
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Optional CONTENT matcher for a constraint: a regex tested against the lines a
|
|
2
|
+
* diff/edit ADDS. When a constraint carries one, the gate decides a violation by
|
|
3
|
+
* CONTENT (the rule was actually broken) instead of by bare SCOPE-touch.
|
|
4
|
+
*
|
|
5
|
+
* Why this matters: scope-touch enforcement is so blunt that strict had to fail
|
|
6
|
+
* OPEN once a guarded file changed (the "staleness" gate) or it would block every
|
|
7
|
+
* edit in scope — which silently retracts the teeth over a file's normal life
|
|
8
|
+
* (dec_e0a36efbf5). A content match is verifiable PER COMMIT, so it needs no
|
|
9
|
+
* staleness proxy: a vouched, content-matched invariant keeps blocking the actual
|
|
10
|
+
* violation across the whole life of the file, and stays quiet on edits that don't
|
|
11
|
+
* break it. Bad user/LLM regex is compiled defensively and is simply inert. */
|
|
12
|
+
export function constraintMatcher(pattern) {
|
|
13
|
+
if (!pattern)
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
return new RegExp(pattern);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null; // malformed pattern → inert, never throws
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** True iff any ADDED line trips the constraint's content matcher. */
|
|
23
|
+
export function contentViolates(re, addedLines) {
|
|
24
|
+
if (!re)
|
|
25
|
+
return false;
|
|
26
|
+
return addedLines.some((l) => re.test(l));
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=constraintmatch.js.map
|
package/dist/core/correction.js
CHANGED
|
@@ -67,6 +67,7 @@ export function buildCorrectionConstraint(input, now) {
|
|
|
67
67
|
scope,
|
|
68
68
|
severity,
|
|
69
69
|
enforcement: "advisory_v1",
|
|
70
|
+
match: null,
|
|
70
71
|
rationale: input.rationale ?? "Captured from a human correction of the agent (Never Twice).",
|
|
71
72
|
source_decision: input.source_decision ?? null,
|
|
72
73
|
violations: [],
|
package/dist/core/hookpolicy.js
CHANGED
|
@@ -1,20 +1,33 @@
|
|
|
1
|
+
import { constraintMatcher, contentViolates } from "./constraintmatch.js";
|
|
1
2
|
/** Return a BlockingHit if editing `file` (repo-relative) hits a blocking
|
|
2
|
-
* invariant directly or through its blast radius, else null.
|
|
3
|
-
|
|
3
|
+
* invariant directly or through its blast radius, else null. `proposedAddedLines`
|
|
4
|
+
* are the lines the edit would ADD: a CONTENT-MATCHED invariant (one carrying a
|
|
5
|
+
* `match` regex) denies ONLY when those lines actually trip it — so it stays quiet
|
|
6
|
+
* on edits that don't break the rule, instead of blocking every edit in scope
|
|
7
|
+
* (dec_e0a36efbf5). Scope-only invariants keep the blunt scope-touch behavior. */
|
|
8
|
+
export function blockingInScope(store, file, proposedAddedLines = []) {
|
|
4
9
|
for (const c of store.checkConstraints(file)) {
|
|
5
|
-
if (c.severity
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
if (c.severity !== "blocking")
|
|
11
|
+
continue;
|
|
12
|
+
const re = constraintMatcher(c.match);
|
|
13
|
+
if (re && !contentViolates(re, proposedAddedLines))
|
|
14
|
+
continue; // content-matched & not tripped → allow
|
|
15
|
+
return {
|
|
16
|
+
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.`,
|
|
17
|
+
};
|
|
10
18
|
}
|
|
11
19
|
for (const b of store.blastRadiusFiles(file)) {
|
|
12
20
|
for (const c of store.checkConstraints(b.file)) {
|
|
13
|
-
if (c.severity
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
if (c.severity !== "blocking")
|
|
22
|
+
continue;
|
|
23
|
+
// A content matcher tests the EDITED file's own added lines; it has nothing to
|
|
24
|
+
// assert about a transitive dependency, so content-matched invariants don't fire
|
|
25
|
+
// via blast radius — only scope-only invariants keep the blast-radius warning.
|
|
26
|
+
if (constraintMatcher(c.match))
|
|
27
|
+
continue;
|
|
28
|
+
return {
|
|
29
|
+
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.`,
|
|
30
|
+
};
|
|
18
31
|
}
|
|
19
32
|
}
|
|
20
33
|
return null;
|
package/dist/core/types.js
CHANGED
|
@@ -165,6 +165,11 @@ export const ConstraintSchema = z.object({
|
|
|
165
165
|
scope: z.array(z.string()).default([]).describe("glob(s) it applies to"),
|
|
166
166
|
severity: z.enum(["advisory", "warning", "blocking"]).default("warning"),
|
|
167
167
|
enforcement: z.enum(["advisory_v1", "ci", "manual"]).default("advisory_v1"),
|
|
168
|
+
// Optional CONTENT matcher (regex): the gate blocks when an ADDED line matches it,
|
|
169
|
+
// instead of on bare scope-touch. A content-verifiable invariant is decided per
|
|
170
|
+
// commit, so it is immune to file-change "staleness" and keeps its teeth across the
|
|
171
|
+
// file's whole life — and stays quiet on edits that don't break it (dec_e0a36efbf5).
|
|
172
|
+
match: z.string().nullable().default(null),
|
|
168
173
|
rationale: z.string().default(""),
|
|
169
174
|
source_decision: z.string().nullable().default(null),
|
|
170
175
|
violations: z.array(z.string()).default([]),
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { verdict } from "../core/checkreport.js";
|
|
2
|
+
import { pathMatchesGlob } from "../core/glob.js";
|
|
3
|
+
const surfaced = (v) => v === "block" || v === "warn";
|
|
4
|
+
const matches = (expect, got) => (expect === "catch" ? surfaced(got) : got === expect);
|
|
5
|
+
/** Parse + validate a hand-authored golden set. */
|
|
6
|
+
export function loadGuardCases(json) {
|
|
7
|
+
const raw = JSON.parse(json);
|
|
8
|
+
if (!Array.isArray(raw))
|
|
9
|
+
throw new Error("expected a JSON array of guard cases");
|
|
10
|
+
return raw.map((c, i) => {
|
|
11
|
+
const x = c;
|
|
12
|
+
if (!x || typeof x.name !== "string" || !Array.isArray(x.files) || !["block", "warn", "pass", "catch"].includes(x.expect)) {
|
|
13
|
+
throw new Error(`case ${i}: need { name, files: [..], expect: "block"|"warn"|"pass"|"catch", diff? }`);
|
|
14
|
+
}
|
|
15
|
+
return { name: x.name, files: x.files, diff: typeof x.diff === "string" ? x.diff : "", expect: x.expect };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** Score every case through the real guard pipeline. */
|
|
19
|
+
export function evalGuards(store, cases) {
|
|
20
|
+
const now = new Date().toISOString();
|
|
21
|
+
const perCase = cases.map((c) => {
|
|
22
|
+
// Treat each changed file as just-edited (so staleness is decided by the record's own
|
|
23
|
+
// last_verified, exactly as in a live `hunch check --strict`), via the SHARED report builder.
|
|
24
|
+
const got = verdict(store.buildCheckReport(c.files, c.diff ?? "", { strict: true, lastChange: () => now }));
|
|
25
|
+
return { name: c.name, expect: c.expect, got, ok: matches(c.expect, got) };
|
|
26
|
+
});
|
|
27
|
+
const shouldSurface = cases.filter((c) => c.expect !== "pass").length;
|
|
28
|
+
const shouldPass = cases.length - shouldSurface;
|
|
29
|
+
return {
|
|
30
|
+
total: cases.length,
|
|
31
|
+
shouldSurface,
|
|
32
|
+
surfaced: perCase.filter((p) => p.expect !== "pass" && surfaced(p.got)).length,
|
|
33
|
+
hardBlocked: perCase.filter((p) => p.expect !== "pass" && p.got === "block").length,
|
|
34
|
+
shouldPass,
|
|
35
|
+
falsePositives: perCase.filter((p) => p.expect === "pass" && surfaced(p.got)).length,
|
|
36
|
+
accuracy: perCase.length ? perCase.filter((p) => p.ok).length / perCase.length : 0,
|
|
37
|
+
perCase,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Scaffold a STARTER set from the live graph: every active, vouched blocking constraint →
|
|
41
|
+
* a CATCH case (a file in its scope — a change there must not slip silently past the gate);
|
|
42
|
+
* a few unrelated paths → PASS cases (the precision side — the gate must NOT over-flag). Both
|
|
43
|
+
* sides are true ground truth. Hand-add regressions / near-misses for fuller coverage. */
|
|
44
|
+
export function generateGuardCases(store) {
|
|
45
|
+
const cases = [];
|
|
46
|
+
const blocking = store
|
|
47
|
+
.recs("constraints")
|
|
48
|
+
.filter((c) => c.status === "active" && c.severity === "blocking" && c.scope.length && isVouched(c.provenance?.source));
|
|
49
|
+
for (const c of blocking) {
|
|
50
|
+
cases.push({ name: `CATCH · ${c.statement.slice(0, 56)}`, files: [pathForGlob(c.scope[0])], expect: "catch" });
|
|
51
|
+
}
|
|
52
|
+
for (const p of ["docs/__eval__notes.md", "scripts/__eval__.txt", ".github/__eval__.yml"]) {
|
|
53
|
+
if (!blocking.some((c) => c.scope.some((g) => pathMatchesGlob(p, g)))) {
|
|
54
|
+
cases.push({ name: `PASS · unrelated ${p}`, files: [p], expect: "pass" });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return cases;
|
|
58
|
+
}
|
|
59
|
+
const isVouched = (source) => !!source && (source.includes("human_confirmed") || source === "derived");
|
|
60
|
+
/** A concrete path that matches a scope glob, for a synthetic case. Exact-file scopes pass
|
|
61
|
+
* through; wildcard scopes get a representative file inside them. */
|
|
62
|
+
function pathForGlob(glob) {
|
|
63
|
+
if (!/[*]/.test(glob))
|
|
64
|
+
return glob;
|
|
65
|
+
let p = glob.replace(/\*\*/g, "x").replace(/\*/g, "x").replace(/\/+/g, "/").replace(/\/$/, "");
|
|
66
|
+
if (!/\.[a-z0-9]+$/i.test(p))
|
|
67
|
+
p += "/__eval__.ts";
|
|
68
|
+
return p.replace(/^\.?\//, "");
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=guards.js.map
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -22,6 +22,7 @@ import { gitCommonDir } from "../extractors/git.js";
|
|
|
22
22
|
import { pathMatchesGlob } from "../core/glob.js";
|
|
23
23
|
import { edgeId } from "../core/ids.js";
|
|
24
24
|
import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
|
|
25
|
+
import { constraintMatcher, contentViolates } from "../core/constraintmatch.js";
|
|
25
26
|
import { analyzeDiff } from "../extractors/diff.js";
|
|
26
27
|
export class HunchStore {
|
|
27
28
|
paths;
|
|
@@ -652,16 +653,36 @@ export class HunchStore {
|
|
|
652
653
|
movedFrom: [...an.filesRenamed.map((r) => r.from), ...an.filesDeleted],
|
|
653
654
|
removedNames: new Set(an.removedSymbols.map((s) => s.name)),
|
|
654
655
|
});
|
|
655
|
-
const directReport = [
|
|
656
|
+
const directReport = [];
|
|
657
|
+
for (const { c, files: fs } of direct.values()) {
|
|
658
|
+
const re = constraintMatcher(c.match);
|
|
659
|
+
if (re) {
|
|
660
|
+
// CONTENT-MATCHED: decide by whether an ADDED line in the matched files actually
|
|
661
|
+
// breaks the rule — not by bare scope-touch. A commit that touches the scope but
|
|
662
|
+
// doesn't trip the matcher COMPLIES → drop it (no noise). A real hit blocks WITHOUT
|
|
663
|
+
// the staleness gate: content is verified per commit, so file churn can't retract
|
|
664
|
+
// the teeth (dec_e0a36efbf5). Empty diff ⇒ can't prove a violation ⇒ treat as clean.
|
|
665
|
+
const added = fs.flatMap((f) => an.addedLinesByFile.get(f) ?? []);
|
|
666
|
+
if (!contentViolates(re, added))
|
|
667
|
+
continue;
|
|
668
|
+
const strictBlocks = isStrictBlocker(c, false);
|
|
669
|
+
directReport.push({
|
|
670
|
+
id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
|
|
671
|
+
files: fs, strictBlocks,
|
|
672
|
+
downgrade: c.severity === "blocking" && !strictBlocks ? "low-confidence" : undefined,
|
|
673
|
+
why: this.causalChain(c.id),
|
|
674
|
+
});
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
656
677
|
const stale = staleIds.has(c.id);
|
|
657
678
|
const strictBlocks = isStrictBlocker(c, stale);
|
|
658
|
-
|
|
679
|
+
directReport.push({
|
|
659
680
|
id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
|
|
660
681
|
files: fs, strictBlocks,
|
|
661
682
|
downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
|
|
662
683
|
why: this.causalChain(c.id),
|
|
663
|
-
};
|
|
664
|
-
}
|
|
684
|
+
});
|
|
685
|
+
}
|
|
665
686
|
return {
|
|
666
687
|
fileCount: files.length,
|
|
667
688
|
strict: opts.strict,
|
|
@@ -280,6 +280,7 @@ function promoteConstraint(store, bug) {
|
|
|
280
280
|
scope,
|
|
281
281
|
severity: bug.severity === "critical" ? "blocking" : "warning",
|
|
282
282
|
enforcement: "advisory_v1",
|
|
283
|
+
match: null,
|
|
283
284
|
rationale: `Derived from ${bug.id}: ${bug.root_cause || bug.symptom}`,
|
|
284
285
|
source_decision: null,
|
|
285
286
|
violations: [],
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
|
-
"description": "Hunch — an Engineering Memory OS: a persistent, git-native
|
|
6
|
+
"description": "Hunch — an Engineering Memory OS: a persistent, git-native graph of the decisions, bugs, and rules behind your code, served to any MCP coding assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",
|
|
7
7
|
"homepage": "https://hunch-pi.vercel.app",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|