@davesheffer/hunch 0.12.2 → 0.13.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 +25 -0
- package/dist/cli/index.js +6 -1
- package/dist/core/correction.js +79 -0
- package/dist/core/ids.js +4 -2
- package/dist/mcp/server.js +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -195,6 +195,31 @@ The hook never breaks your flow: any error or unrecognized input emits nothing a
|
|
|
195
195
|
none, every level degrades to context-only. Opt out of the hooks entirely with `hunch init
|
|
196
196
|
--no-agent-hooks`.
|
|
197
197
|
|
|
198
|
+
## Never Twice: corrections become enforced invariants
|
|
199
|
+
|
|
200
|
+
The most expensive failure in AI coding is being corrected and then *re-corrected* — you
|
|
201
|
+
tell the agent "no, never call the pay-per-token API here," it complies once, and next
|
|
202
|
+
session it does it again because the feedback was stored as advisory text, not enforced.
|
|
203
|
+
|
|
204
|
+
Hunch closes that loop. When you correct the agent, it captures the rule as a **first-class
|
|
205
|
+
Constraint** (provenance `human_confirmed`) via the `hunch_record_correction` MCP tool — and
|
|
206
|
+
from then on the **same pre-edit hook + CI Constraint Guard** hold *every* assistant to it:
|
|
207
|
+
|
|
208
|
+
```text
|
|
209
|
+
You: "no — never import lodash, we ship our own utils"
|
|
210
|
+
Agent: calls hunch_record_correction({ rule: "never import lodash; use src/utils",
|
|
211
|
+
scope_hint_file: "src/cart.ts", severity: "blocking" })
|
|
212
|
+
→ con_… recorded. A later edit that adds `import _ from "lodash"` to that scope is DENIED
|
|
213
|
+
(strict firmness) and the PR fails CI — in Cursor, Copilot, Windsurf, or Claude Code alike.
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The `UserPromptSubmit` hook nudges the agent to persist a rule whenever your prompt reads
|
|
217
|
+
like a correction ("no…", "that's wrong", "never do X"), so capture is one frictionless step
|
|
218
|
+
rather than a discipline. Scoping is conservative by default (the file you were in); a
|
|
219
|
+
repo-wide (`**`) rule is only blocking when you pass `applies_to_all`, so one correction
|
|
220
|
+
can't silently gate the whole tree. Because it's the *same* constraint machinery, a
|
|
221
|
+
correction is enforced exactly like a hand-authored invariant — see firmness above.
|
|
222
|
+
|
|
198
223
|
## Semantic search (optional)
|
|
199
224
|
|
|
200
225
|
By default `hunch query` and the `hunch_query` MCP tool use fast keyword (FTS) search —
|
package/dist/cli/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { execFileSync, spawnSync } from "node:child_process";
|
|
|
18
18
|
import { relative } from "node:path";
|
|
19
19
|
import { Command } from "commander";
|
|
20
20
|
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
21
|
+
import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
|
|
21
22
|
import { HunchStore } from "../store/hunchStore.js";
|
|
22
23
|
import { selectEmbedder } from "../store/embedder.js";
|
|
23
24
|
import { indexRepo } from "../extractors/indexer.js";
|
|
@@ -736,7 +737,11 @@ program
|
|
|
736
737
|
if (firmness === "off")
|
|
737
738
|
return;
|
|
738
739
|
if (evt.hook_event_name === "UserPromptSubmit") {
|
|
739
|
-
|
|
740
|
+
// When the prompt reads like a correction ("no / that's wrong / never X"),
|
|
741
|
+
// nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
|
|
742
|
+
// not just obey it this once and forget it next session.
|
|
743
|
+
const text = looksLikeCorrection(evt.prompt) ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
|
|
744
|
+
emitContext("UserPromptSubmit", text);
|
|
740
745
|
return;
|
|
741
746
|
}
|
|
742
747
|
if (evt.hook_event_name !== "PreToolUse")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** "Never Twice" — turn a human correction of the agent into a first-class,
|
|
2
|
+
* enforced Constraint (DESIGN: Correction Capture → Enforced Constraint).
|
|
3
|
+
*
|
|
4
|
+
* Two pure, client-agnostic pieces, factored out of the MCP server and the
|
|
5
|
+
* agent hook so they are unit-testable without spinning either up:
|
|
6
|
+
* - looksLikeCorrection(): does a user prompt read like "no / that's wrong /
|
|
7
|
+
* never do X" — the cue to nudge the agent to persist it.
|
|
8
|
+
* - buildCorrectionConstraint(): mint the Constraint record (human-confirmed,
|
|
9
|
+
* scoped conservatively) that the pre-edit hook + CI guard then enforce.
|
|
10
|
+
*/
|
|
11
|
+
import { constraintId } from "./ids.js";
|
|
12
|
+
import { toPosixTarget } from "./paths.js";
|
|
13
|
+
/** Correction cues. Deliberately conservative — anchored to imperative/rebuke
|
|
14
|
+
* phrasing, not bare "no", so ordinary conversational negation ("no idea",
|
|
15
|
+
* "no problem") doesn't train users to ignore the nudge (research risk #5). */
|
|
16
|
+
const CORRECTION_PATTERNS = [
|
|
17
|
+
// OPENS with a rebuke — but exclude benign "no problem / no idea / no test exists…".
|
|
18
|
+
// The exclusion list guards against stateful conversational negation ("no tests pass",
|
|
19
|
+
// "no way to fix this") firing the nudge and training users to ignore it.
|
|
20
|
+
/^\s*no\b(?!\s+(problem|worries|idea|rush|need|biggie|thanks|thank|prob|clue|luck|difference|harm|reason|point|test|tests|way|ways|chance|context|functions?|method|file|files|change|changes|diff|other|more))/i,
|
|
21
|
+
/^\s*(nope|stop)\b/i,
|
|
22
|
+
/\b(that'?s|that is|this is) (wrong|incorrect|not right|not what)\b/i,
|
|
23
|
+
/\b(never|do not ever|don'?t ever) (do|use|call|add|write|put|import|commit|touch)\b/i,
|
|
24
|
+
/\b(don'?t|do not) (do|use|call|add|write|put|commit) (that|this|it)\b/i,
|
|
25
|
+
/\b(you must|must always|you should always|make sure (to|you|that you))\b/i,
|
|
26
|
+
/\b(i (already )?told you|i said|as i said|like i said)\b/i,
|
|
27
|
+
/\b(undo|revert) (that|this|it|your)\b/i,
|
|
28
|
+
/\b(not like that|don'?t do (that|this)( again)?|stop doing (that|this))\b/i,
|
|
29
|
+
];
|
|
30
|
+
export function looksLikeCorrection(prompt) {
|
|
31
|
+
if (!prompt || typeof prompt !== "string")
|
|
32
|
+
return false;
|
|
33
|
+
return CORRECTION_PATTERNS.some((re) => re.test(prompt));
|
|
34
|
+
}
|
|
35
|
+
/** One-line nudge appended to the UserPromptSubmit hook context when a prompt
|
|
36
|
+
* reads like a correction — surfaces the write tool so the rule gets ENFORCED,
|
|
37
|
+
* not merely remembered. Client-agnostic (no Claude-only wording). */
|
|
38
|
+
export const CORRECTION_NUDGE = "This looks like a correction. If it's a rule the agent should never break again, " +
|
|
39
|
+
"call hunch_record_correction({ rule, scope_hint_file, severity, applies_to_all }) so it " +
|
|
40
|
+
"becomes an enforced, scoped constraint (held at edit-time and in CI) — not a one-off the next session forgets. " +
|
|
41
|
+
"Use severity:\"blocking\" only when the human said never/must; set applies_to_all:true only if the rule is genuinely repo-wide.";
|
|
42
|
+
/**
|
|
43
|
+
* Build the Constraint a correction mints. Pure (caller passes `now`), so the
|
|
44
|
+
* scope/severity policy is testable in isolation. Key safety rule (research
|
|
45
|
+
* risk #2 — the scope footgun): a repo-wide ("**") constraint may only be
|
|
46
|
+
* BLOCKING when the caller explicitly set applies_to_all; otherwise a single
|
|
47
|
+
* mis-scoped correction would deny every edit under strict firmness, so we
|
|
48
|
+
* down-rank it to a warning.
|
|
49
|
+
*/
|
|
50
|
+
export function buildCorrectionConstraint(input, now) {
|
|
51
|
+
const rule = input.rule.trim();
|
|
52
|
+
if (!rule)
|
|
53
|
+
throw new Error("rule must not be empty");
|
|
54
|
+
// A blank/"." scope hint would mint a meaningless or repo-wide constraint by
|
|
55
|
+
// accident, so fall back to "**" (which the severity guard below then keeps
|
|
56
|
+
// non-blocking unless applies_to_all was explicitly set).
|
|
57
|
+
const hinted = input.scope_hint_file ? toPosixTarget(input.scope_hint_file) : "";
|
|
58
|
+
const scope = input.applies_to_all || !hinted || hinted === "." ? ["**"] : [hinted];
|
|
59
|
+
const repoWide = scope.length === 1 && scope[0] === "**";
|
|
60
|
+
let severity = input.severity ?? "warning";
|
|
61
|
+
if (severity === "blocking" && repoWide && !input.applies_to_all)
|
|
62
|
+
severity = "warning";
|
|
63
|
+
return {
|
|
64
|
+
id: constraintId(rule),
|
|
65
|
+
type: input.type ?? "correctness",
|
|
66
|
+
statement: rule,
|
|
67
|
+
scope,
|
|
68
|
+
severity,
|
|
69
|
+
enforcement: "advisory_v1",
|
|
70
|
+
rationale: input.rationale ?? "Captured from a human correction of the agent (Never Twice).",
|
|
71
|
+
source_decision: input.source_decision ?? null,
|
|
72
|
+
violations: [],
|
|
73
|
+
status: "active",
|
|
74
|
+
valid_from: now,
|
|
75
|
+
valid_to: null,
|
|
76
|
+
provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: now },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=correction.js.map
|
package/dist/core/ids.js
CHANGED
|
@@ -32,8 +32,10 @@ export function decisionId(seed) {
|
|
|
32
32
|
export function bugId(seed) {
|
|
33
33
|
return "bug_" + shortHash(seed);
|
|
34
34
|
}
|
|
35
|
-
/** Constraint id seeded by its statement.
|
|
35
|
+
/** Constraint id seeded by its statement. Trim + lowercase so trivial
|
|
36
|
+
* whitespace/case variants of the same rule collapse to one id (idempotent
|
|
37
|
+
* re-capture), instead of minting a duplicate constraint. */
|
|
36
38
|
export function constraintId(statement) {
|
|
37
|
-
return "con_" + shortHash(statement.toLowerCase());
|
|
39
|
+
return "con_" + shortHash(statement.trim().toLowerCase());
|
|
38
40
|
}
|
|
39
41
|
//# sourceMappingURL=ids.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -13,6 +13,7 @@ import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
|
13
13
|
import { HunchStore } from "../store/hunchStore.js";
|
|
14
14
|
import { selectEmbedder } from "../store/embedder.js";
|
|
15
15
|
import { decisionId } from "../core/ids.js";
|
|
16
|
+
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
16
17
|
import { revParse, asOfDate } from "../extractors/git.js";
|
|
17
18
|
import { formatContext } from "../core/format.js";
|
|
18
19
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
@@ -286,6 +287,36 @@ export function buildServer(root) {
|
|
|
286
287
|
return err(`Failed to record decision: ${e.message}`);
|
|
287
288
|
}
|
|
288
289
|
});
|
|
290
|
+
// -- hunch_record_correction (write-back: "Never Twice") ------------------
|
|
291
|
+
server.registerTool("hunch_record_correction", {
|
|
292
|
+
title: "Capture a correction as an enforced constraint (Never Twice)",
|
|
293
|
+
description: "When a human corrects the agent ('no, do it this way' / 'never call X here'), persist that correction as a first-class, SCOPED Constraint with provenance — so the pre-edit hook and the CI Constraint Guard hold EVERY assistant to it from now on, instead of it being forgotten next session. Writes to the shared .hunch/ graph (client-agnostic). Set severity:'blocking' only when the human said never/must; set applies_to_all:true only when the rule is genuinely repo-wide (otherwise it is scoped to scope_hint_file).",
|
|
294
|
+
inputSchema: {
|
|
295
|
+
rule: z.string().describe("The invariant in the human's words, e.g. \"never call the pay-per-token API here\"."),
|
|
296
|
+
scope_hint_file: z.string().optional().describe("A file the correction was about; scopes the constraint to it (the conservative default)."),
|
|
297
|
+
severity: z.enum(["advisory", "warning", "blocking"]).optional().describe("Default 'warning'. Use 'blocking' only for a hard never/must rule."),
|
|
298
|
+
applies_to_all: z.boolean().optional().describe("True ONLY if the rule is genuinely repo-wide (scopes to **); required to make a repo-wide rule blocking."),
|
|
299
|
+
type: z.enum(["security", "performance", "correctness", "architecture", "compliance"]).optional(),
|
|
300
|
+
rationale: z.string().optional().describe("Why it must hold."),
|
|
301
|
+
source_decision: z.string().optional().describe("id of a decision this correction derives from."),
|
|
302
|
+
},
|
|
303
|
+
}, async (input) => {
|
|
304
|
+
try {
|
|
305
|
+
if (!input.rule || !input.rule.trim())
|
|
306
|
+
return err("rule is required — state the invariant in plain words.");
|
|
307
|
+
const rec = buildCorrectionConstraint(input, new Date().toISOString());
|
|
308
|
+
const existing = store.json.get("constraints", rec.id);
|
|
309
|
+
store.json.put("constraints", rec);
|
|
310
|
+
store.reindex();
|
|
311
|
+
const enforce = rec.severity === "blocking"
|
|
312
|
+
? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
|
|
313
|
+
: "flags violating edits and PRs (advisory)";
|
|
314
|
+
return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}). It now ${enforce}.`);
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
return err(`Failed to record correction: ${e.message}`);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
289
320
|
return server;
|
|
290
321
|
}
|
|
291
322
|
function provLine(record) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.",
|