@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 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
- emitContext("UserPromptSubmit", HOOK_REMINDER);
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
@@ -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.12.2",
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.",