@davesheffer/hunch 1.3.0 → 1.4.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
@@ -7,30 +7,34 @@
7
7
  [![node](https://img.shields.io/badge/node-%E2%89%A522.13-2742ff)](https://nodejs.org)
8
8
  [![MCP](https://img.shields.io/badge/MCP-native-2742ff)](https://modelcontextprotocol.io)
9
9
 
10
- > **A linter checks whether code matches a *pattern*. Hunch checks whether code still matches your *architecture***
11
- > and blocks the AI change that breaks it, citing the decision and the past bug it would reopen.
12
- > The semantic invariants pattern-SAST can't express (layering, must-reach, dependency direction),
13
- > enforced deterministically over a **git-native** graph of *why* across any MCP assistant.
10
+ > **Your repo remembers *what* changed. Hunch makes it remember *why*** the decisions, the
11
+ > trade-offs, the bugs you already paid for and keeps every AI coding session consistent with them.
12
+ > When a change would quietly undo something you decided on purpose, Hunch notices and shows the
13
+ > receipt: which decision, and which past bug it protects you from. A linter checks *patterns*;
14
+ > Hunch understands your *architecture* (layering, must-reach, dependency direction) — deterministically,
15
+ > from a **git-native** graph, across any MCP assistant.
14
16
 
15
17
  ```bash
16
18
  npm i -g @davesheffer/hunch
17
- cd your-repo && hunch init
19
+ cd your-repo && hunch init # 2 minutes; advisory by default — nothing blocks until you say so
18
20
 
19
- # record an architectural invariant the kind Semgrep/SonarQube structurally can't express
21
+ # teach it one architectural rule, in plain words with the why
20
22
  hunch conform --add "controllers never reach the DB directly — go through the service layer" \
21
- --assert not-calls --subject listOrders --object dbQuery --why "the Mar-2025 N+1 meltdown"
23
+ --assert not-calls --subject listOrders --object dbQuery --why "the Mar-2025 N+1 incident"
22
24
 
23
- hunch conform --strict # ✅/⛔ deterministic gatewire into CI; runs on every AI change
25
+ hunch conform # deterministic checkand when you're ready, --strict wires it into CI
24
26
  ```
25
27
 
26
- > An AI "optimizes" the controller to query the DB directly. **Semgrep: green. SonarQube: green.**
27
- > (it's a legitimate internal import no bad pattern.) **Hunch: ⛔ BLOCKED** — *"listOrders now reaches
28
- > dbQuery VIOLATED · why: the Mar-2025 N+1 meltdown · prevents recurrence of bug_0317."* See
28
+ > Here's the moment it earns its keep: an AI "optimizes" your controller to query the DB directly.
29
+ > Pattern tools stay **green** — it's a legitimate internal import, no bad pattern to match.
30
+ > Hunch quietly flags it: *"listOrders now reaches dbQuery · why this matters: the Mar-2025 N+1
31
+ > incident · protects against a repeat of bug_0317."* You decide what happens next — advisory
32
+ > shows the note, **strict** (opt-in) holds the change. Watch the whole loop in 15 seconds:
29
33
  > [`demo/architectural-conformance.sh`](demo/architectural-conformance.sh).
30
34
 
31
- **It works both ways prevent *and* catch and you need both:**
32
- - **Prevent** — in a reproducible benchmark ([`bench/`](bench/architectural-conformance.md): n=90, Haiku/Sonnet/Opus, 3 invariant classes), the recorded invariant in context cut architectural violations **58% → 16%** overall (Sonnet **67% → 0%**). But prevention is *necessary, not sufficient*: **even Opus ignored a layering rule 60% of the time when told.** Each violation passes a linter clean.
33
- - **Catch** — which is exactly why the deterministic gate exists. `hunch check --strict` (the pre-commit hook + the [`hunch ci`](https://hunch-pi.vercel.app/docs#ci) PR gate) **blocks** what the model ignores with the receipt, **no model in the gate**. Injection helps; the gate is the guarantee.
35
+ **It helps twicebefore the change, and after:**
36
+ - **Before** — the recorded rule rides into the AI's context. In a reproducible benchmark ([`bench/`](bench/architectural-conformance.md): n=90, three models, 3 invariant classes), that alone cut architectural drift **58% → 16%** (one model: **67% → 0%**).
37
+ - **After** — reminders help, but models still drift even when told (the same benchmark measured it). So a deterministic check — [`hunch check`](https://hunch-pi.vercel.app/docs#ci) on commit or in your PR gate catches what reminders miss, always with the receipt, **no model in the loop**. Advisory first; you choose the [firmness](https://hunch-pi.vercel.app/docs#firmness).
34
38
 
35
39
  <sub>Works with **Claude Code, Cursor, Copilot, Windsurf & Google Antigravity** from one shared, git-native graph.</sub>
36
40
 
package/dist/cli/index.js CHANGED
@@ -50,6 +50,7 @@ import { formatContext, formatStructure } from "../core/format.js";
50
50
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
51
51
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
52
52
  import { injectionMode } from "../core/hookcache.js";
53
+ import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
53
54
  import { draftDuplicateOf } from "../core/dupdetect.js";
54
55
  import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
55
56
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
@@ -1556,11 +1557,47 @@ program
1556
1557
  const firmness = readConfig(paths).firmness;
1557
1558
  if (firmness === "off")
1558
1559
  return;
1560
+ // Verification pipeline (delivery enforced, not hoped for — see core/pipeline.ts).
1561
+ // PostToolUse records facts; Stop gates on them. Both are pipeline-only events,
1562
+ // handled before the grounding dispatch below.
1563
+ if (evt.hook_event_name === "PostToolUse" && evt.session_id && pipelineEnabled()) {
1564
+ let st = loadPipelineState(evt.session_id);
1565
+ if (/^(Edit|Write|MultiEdit)$/.test(evt.tool_name ?? "")) {
1566
+ const p = evt.tool_input?.file_path;
1567
+ if (p)
1568
+ st = onEdit(st, toRepoRel(root, p));
1569
+ }
1570
+ else if (evt.tool_name === "Bash" || evt.tool_name === "PowerShell") {
1571
+ st = onCommand(st, String(evt.tool_input?.command ?? ""));
1572
+ }
1573
+ else if (evt.tool_name === "Skill") {
1574
+ st = onSkill(st, String(evt.tool_input?.skill ?? ""));
1575
+ }
1576
+ savePipelineState(evt.session_id, st);
1577
+ return;
1578
+ }
1579
+ if (evt.hook_event_name === "Stop" && evt.session_id && pipelineEnabled()) {
1580
+ const st = loadPipelineState(evt.session_id);
1581
+ const verdict = stopVerdict(st, firmness);
1582
+ if (verdict.block) {
1583
+ savePipelineState(evt.session_id, verdict.state);
1584
+ process.stdout.write(JSON.stringify({ decision: "block", reason: verdict.reason }));
1585
+ }
1586
+ return;
1587
+ }
1559
1588
  if (evt.hook_event_name === "UserPromptSubmit") {
1560
1589
  // When the prompt reads like a correction ("no / that's wrong / never X"),
1561
1590
  // nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
1562
1591
  // not just obey it this once and forget it next session.
1563
- const text = looksLikeCorrection(evt.prompt) ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
1592
+ let text = looksLikeCorrection(evt.prompt) ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
1593
+ // Pipeline turn bookkeeping (fresh block budget) + the one nag that must
1594
+ // repeat: edits from an earlier turn still unverified.
1595
+ if (evt.session_id && pipelineEnabled()) {
1596
+ const st = onPrompt(loadPipelineState(evt.session_id));
1597
+ savePipelineState(evt.session_id, st);
1598
+ if (!st.verifyAfterEdit)
1599
+ text += `\n\n${UNVERIFIED_NAG}`;
1600
+ }
1564
1601
  // Once per session is enough for the availability reminder — repeating it
1565
1602
  // every prompt burns context for zero information. A correction nudge has
1566
1603
  // different content, so it always comes through (dec_244397d920).
@@ -1579,8 +1616,12 @@ program
1579
1616
  try {
1580
1617
  const decisions = s.json.loadAll("decisions");
1581
1618
  const { recent, roadmap, pendingReview } = nowData(decisions, 3);
1582
- if (!decisions.length)
1619
+ if (!decisions.length) {
1620
+ // Fresh graph: nothing to orient on, but the operating loop still ships.
1621
+ if (pipelineEnabled())
1622
+ emitContext("SessionStart", PIPELINE_LOOP);
1583
1623
  return;
1624
+ }
1584
1625
  const L = [];
1585
1626
  L.push(`🧠 Hunch orientation — ${decisions.length} decision(s) in the graph.`);
1586
1627
  if (recent.length) {
@@ -1594,6 +1635,10 @@ program
1594
1635
  if (pendingReview > 0)
1595
1636
  L.push(`${pendingReview} auto-draft(s) awaiting \`hunch review\`.`);
1596
1637
  L.push("Orient further: hunch_context(task) · hunch_structure() · `hunch now`.");
1638
+ // The operating loop rides session start — guaranteed delivery, once
1639
+ // (the zod bench showed ambient skills are read in ~0% of sessions).
1640
+ if (pipelineEnabled())
1641
+ L.push("", PIPELINE_LOOP);
1597
1642
  emitContext("SessionStart", L.join("\n"));
1598
1643
  }
1599
1644
  finally {
@@ -29,8 +29,11 @@ export function renderDocGrounding(anchors, decisions) {
29
29
  const rejected = rejectedForTopic(decisions, a.topic);
30
30
  if (rejected.length)
31
31
  line += `\n rejected: ${rejected.slice(0, 3).map((r) => clip(r, 90)).join("; ")}`;
32
- if (a.pin && a.pin !== current.id) {
33
- line += `\n ⚠ this section is PINNED to ${a.pin}, which is no longer current — reconcile the prose with ${current.id}, then re-pin.`;
32
+ // Scan ALL markers for this topic, not just the first: the topic dedupe must not
33
+ // let an earlier unpinned marker swallow a later marker's stale-pin warning.
34
+ const stalePin = anchors.find((x) => x.topic === a.topic && x.pin && x.pin !== current.id)?.pin;
35
+ if (stalePin) {
36
+ line += `\n ⚠ this section is PINNED to ${stalePin}, which is no longer current — reconcile the prose with ${current.id}, then re-pin.`;
34
37
  }
35
38
  parts.push(line);
36
39
  }
@@ -47,6 +47,12 @@ export function markdownDocs(root) {
47
47
  }
48
48
  };
49
49
  walk(root, "", 0);
50
+ // Agent-facing prose lives under dot-dirs the general walk skips: skills and
51
+ // commands are exactly the docs that rot against the graph (a skill preaching
52
+ // a superseded decision misleads every future session) — scan them explicitly.
53
+ for (const sub of [".claude/skills", ".claude/commands"]) {
54
+ walk(join(root, sub), sub, 1);
55
+ }
50
56
  return out;
51
57
  }
52
58
  /** Grade every repo doc. GENERATED wiki pages (hunch:wiki header) are views of
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Verification pipeline (the "enforcement spine"): hooks that guarantee the
3
+ * operating loop — evidence → change → verify → attack → report — instead of
4
+ * hoping the agent reads a skill. Measured motivation (2026-07-08 zod bench):
5
+ * skills-as-files were read in 0/20 sessions; when the same content was
6
+ * guaranteed-delivered, FAIL→PASS flipped on every discriminating cell.
7
+ * Delivery, not content, was the bottleneck — so delivery is enforced here.
8
+ *
9
+ * Gates evaluate OBSERVABLE FACTS recorded from PostToolUse events (which
10
+ * files were edited, which verify-shaped commands ran afterwards) — never the
11
+ * agent's claims. The Stop gate refuses to end a turn with unverified product
12
+ * edits, at most twice per turn: a broken gate degrades to advisory, never a
13
+ * lockout.
14
+ *
15
+ * Firmness mapping (no new knob):
16
+ * off → pipeline inert
17
+ * advisory → inject the loop at SessionStart + nag on unverified edits; no blocks
18
+ * firm → + Stop gate (max 2 blocks per turn)
19
+ * strict → same as firm (strict's extra bite lives in the pre-edit deny gate)
20
+ *
21
+ * State is per-session scratch in the OS tmpdir (NOT .hunch/ — it is derived,
22
+ * disposable, and single-writer), mirroring hookcache.ts. Failure posture is
23
+ * con_03a0b94b2e: any error → do nothing, exit clean. Kill switch: HUNCH_PIPELINE=0.
24
+ */
25
+ import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { join } from "node:path";
28
+ export const emptyState = () => ({
29
+ turn: 0,
30
+ soulInjected: false,
31
+ blocks: 0,
32
+ domains: {},
33
+ editedFiles: [],
34
+ verifyAfterEdit: true,
35
+ });
36
+ export const DEFAULT_PROFILES = {
37
+ backend: {
38
+ paths: /(^|\/)(src|lib|server|api|core|store|services?)\/|\.(ts|mts|cts|js|mjs|cjs|py|go|rs|java|rb|php)$/i,
39
+ verify: /vitest|jest|pytest|go test|cargo test|tsx --test|npm (run )?test|pnpm (run )?test|tsc|typecheck/i,
40
+ },
41
+ frontend: {
42
+ paths: /\.(tsx|jsx|css|scss|html|vue|svelte)$|(^|\/)(components|pages|site|app|ui)\//i,
43
+ verify: /vite|next (build|dev)|npm run (build|dev)|pnpm (run )?(build|dev)|playwright|storybook|tsc/i,
44
+ },
45
+ tests: {
46
+ paths: /(^|\/)(test|tests|__tests__|e2e|spec)\/|\.(test|spec)\./i,
47
+ verify: /vitest|jest|pytest|tsx --test|npm (run )?test|pnpm (run )?test|playwright/i,
48
+ },
49
+ infra: {
50
+ paths: /Dockerfile|docker-compose|\.tf$|\.tfvars$|(^|\/)(\.github|k8s|helm|terraform|infra|deploy)\/|\.ya?ml$/i,
51
+ verify: /terraform (plan|validate)|docker (build|compose)|kubectl .*--dry-run|helm (lint|template)|actionlint|npm run build/i,
52
+ },
53
+ };
54
+ /** Product code = behavior that ships. Docs, hunch's own graph, and .claude
55
+ * config are not gated — editing THIS machinery must never trip it. */
56
+ export function isProductPath(p) {
57
+ const norm = String(p).replace(/\\/g, "/");
58
+ if (/\.(md|mdx|txt)$/i.test(norm))
59
+ return false;
60
+ if (/(^|\/)\.(claude|hunch)(\/|$)/.test(norm))
61
+ return false;
62
+ return true;
63
+ }
64
+ export function classifyDomains(path, profiles = DEFAULT_PROFILES) {
65
+ const norm = path.replace(/\\/g, "/");
66
+ return Object.entries(profiles)
67
+ .filter(([, d]) => d.paths.test(norm))
68
+ .map(([name]) => name);
69
+ }
70
+ function verifyPattern(state, profiles = DEFAULT_PROFILES) {
71
+ const active = Object.keys(state.domains).filter((d) => profiles[d]);
72
+ const src = (active.length ? active : Object.keys(profiles)).map((d) => profiles[d].verify.source).join("|");
73
+ return new RegExp(src, "i");
74
+ }
75
+ // ------------------------------------------------------- state transitions
76
+ /** New user prompt: fresh block budget. */
77
+ export function onPrompt(state) {
78
+ return { ...state, turn: state.turn + 1, blocks: 0 };
79
+ }
80
+ /** Edit/Write/MultiEdit landed on `path`. */
81
+ export function onEdit(state, path, profiles = DEFAULT_PROFILES) {
82
+ if (!path || !isProductPath(path))
83
+ return state;
84
+ const domains = { ...state.domains };
85
+ for (const d of classifyDomains(path, profiles))
86
+ domains[d] = true;
87
+ return {
88
+ ...state,
89
+ domains,
90
+ verifyAfterEdit: false,
91
+ editedFiles: state.editedFiles.includes(path) ? state.editedFiles : [...state.editedFiles, path],
92
+ };
93
+ }
94
+ /** A shell command ran. Verify-shaped + after an edit → the edits are covered. */
95
+ export function onCommand(state, command, profiles = DEFAULT_PROFILES) {
96
+ if (state.editedFiles.length && verifyPattern(state, profiles).test(command)) {
97
+ return { ...state, verifyAfterEdit: true };
98
+ }
99
+ return state;
100
+ }
101
+ /** A verification-class skill ran (/verify, /code-review) — counts as coverage. */
102
+ export function onSkill(state, skill) {
103
+ if (/code-review|verify|review/i.test(skill))
104
+ return { ...state, verifyAfterEdit: true };
105
+ return state;
106
+ }
107
+ // ------------------------------------------------------------------- gates
108
+ export const PIPELINE_LOOP = [
109
+ "Hunch pipeline — operating loop (enforced on observable facts, not claims):",
110
+ "1. SCOPE — restate the task; define done as something observable (a passing test, a rendered page, a number).",
111
+ "2. EVIDENCE — observe current behavior before editing: run the failing thing, read the real code path, quote the real error.",
112
+ "3. CHANGE — smallest edit that fixes the root cause, not the symptom.",
113
+ "4. VERIFY — after the last edit, RUN the relevant check (test/build/typecheck/plan). A claim without an exit code is not a result.",
114
+ "5. ATTACK — one honest paragraph: what would make this conclusion wrong?",
115
+ "6. REPORT — what ran, what passed, what stays unverified. Failures verbatim.",
116
+ ].join("\n");
117
+ export const UNVERIFIED_NAG = "Hunch pipeline: earlier product edits are still UNVERIFIED — run the relevant test/build/typecheck before claiming anything about them.";
118
+ /** Stop-gate verdict. Blocks only at firm/strict, only with unverified product
119
+ * edits, and at most twice per turn. */
120
+ export function stopVerdict(state, firmness) {
121
+ const gated = firmness === "firm" || firmness === "strict";
122
+ if (!gated || state.verifyAfterEdit || state.editedFiles.length === 0 || state.blocks >= 2)
123
+ return { block: false };
124
+ const domains = Object.keys(state.domains).join(", ") || "generic";
125
+ return {
126
+ block: true,
127
+ state: { ...state, blocks: state.blocks + 1 },
128
+ reason: `Hunch pipeline gate — VERIFY unsatisfied. Product files were edited (${state.editedFiles.slice(-5).join(", ")}) ` +
129
+ `but no verifying command ran afterwards (domain: ${domains}). Do now, in order: ` +
130
+ `(1) run the relevant test/build/typecheck for those files; ` +
131
+ `(2) one honest paragraph attacking your own conclusion — what would make it wrong; ` +
132
+ `(3) report what ran, what passed, what stays unverified. ` +
133
+ `If verification is truly impossible here, say so explicitly and why.`,
134
+ };
135
+ }
136
+ // ------------------------------------------------------------------ storage
137
+ const STATE_DIR = join(tmpdir(), "hunch-pipeline");
138
+ const SWEEP_AGE_MS = 48 * 3600 * 1000;
139
+ export function pipelineEnabled() {
140
+ return process.env.HUNCH_PIPELINE !== "0";
141
+ }
142
+ /** Load session state; on ANY problem return a fresh state (never throw). */
143
+ export function loadPipelineState(sessionId) {
144
+ try {
145
+ const raw = JSON.parse(readFileSync(stateFile(sessionId), "utf8"));
146
+ return { ...emptyState(), ...raw };
147
+ }
148
+ catch {
149
+ return emptyState();
150
+ }
151
+ }
152
+ /** Persist session state (best effort — scratch data, single writer). */
153
+ export function savePipelineState(sessionId, state) {
154
+ try {
155
+ mkdirSync(STATE_DIR, { recursive: true });
156
+ sweep();
157
+ writeFileSync(stateFile(sessionId), JSON.stringify(state));
158
+ }
159
+ catch {
160
+ /* losing scratch state beats breaking the hook */
161
+ }
162
+ }
163
+ function stateFile(sessionId) {
164
+ return join(STATE_DIR, `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
165
+ }
166
+ function sweep() {
167
+ try {
168
+ for (const f of readdirSync(STATE_DIR)) {
169
+ try {
170
+ if (Date.now() - statSync(join(STATE_DIR, f)).mtimeMs > SWEEP_AGE_MS)
171
+ rmSync(join(STATE_DIR, f), { force: true });
172
+ }
173
+ catch {
174
+ /* raced — skip */
175
+ }
176
+ }
177
+ }
178
+ catch {
179
+ /* dir unreadable — skip */
180
+ }
181
+ }
182
+ //# sourceMappingURL=pipeline.js.map
@@ -141,6 +141,17 @@ export function installClaudeHooks(root, hookCmd) {
141
141
  ...keep(json.hooks.SessionStart),
142
142
  { hooks: [{ type: "command", command: hookCmd }] },
143
143
  ];
144
+ // Verification pipeline (core/pipeline.ts): PostToolUse records observable
145
+ // facts (edits, verify commands); Stop refuses to end a turn with unverified
146
+ // product edits at firm/strict firmness. Delivery is enforced, not hoped for.
147
+ json.hooks.PostToolUse = [
148
+ ...keep(json.hooks.PostToolUse),
149
+ { matcher: "Edit|Write|MultiEdit|Bash|PowerShell|Skill", hooks: [{ type: "command", command: hookCmd }] },
150
+ ];
151
+ json.hooks.Stop = [
152
+ ...keep(json.hooks.Stop),
153
+ { hooks: [{ type: "command", command: hookCmd }] },
154
+ ];
144
155
  const next = JSON.stringify(json, null, 2) + "\n";
145
156
  if (existed && before === next)
146
157
  return { path: file, action: "unchanged" };
@@ -53,9 +53,23 @@ const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} m
53
53
  // thin wrappers bind the process clock and id source at the call site (§5 Stage 1).
54
54
  const issueCaptureToken = () => issueToken(randomUUID, Date.now());
55
55
  const consumeCaptureToken = (token) => consumeToken(token, Date.now());
56
- /** The interrogation protocol returned by hunch_capture_decision. */
57
- function grillingProtocol(topic, token) {
58
- return [
56
+ /** The interrogation protocol returned by hunch_capture_decision. With `deciding`,
57
+ * the choice is NOT yet made: the verdict loop runs first so the record's
58
+ * alternatives_rejected are attacks that actually ran — not post-hoc fiction. */
59
+ function grillingProtocol(topic, token, deciding = false) {
60
+ const verdict = [
61
+ "The decision is NOT yet made — run the VERDICT LOOP first (one question at a time), then the grilling rules below.",
62
+ "",
63
+ "VERDICT LOOP:",
64
+ "A. SPLIT — one separable call per verdict, one topic per call. If the ask bundles several decisions, split and run each.",
65
+ "B. CANDIDATES — elicit at least TWO real options (include do-nothing when sane). One candidate = anchoring; keep asking.",
66
+ "C. ATTACK — attack each candidate from INDEPENDENT lenses: product, technical, strategy, economics, and self-consistency (does it contradict a recorded decision or constraint? cite dec_/con_ ids). Every attack cites evidence observed this session; no evidence → mark it plausible and weigh it less.",
67
+ "D. CONVERGE — two or more independent landing attacks kill a candidate. Keep the FAILED attacks too — they are the tested-safe surface; fold them into context. No convergence → prefer the candidate whose failure is REVERSIBLE.",
68
+ "E. TRIPWIRES — for each rejected candidate, ask what future evidence would make it right after all; embed it in the rejected alternative ('rejected X — revisit if Y').",
69
+ "",
70
+ "",
71
+ ].join("\n");
72
+ return (deciding ? verdict : "") + [
59
73
  "You are capturing an engineering decision into Hunch's graph. Run the GRILLING LOOP, then commit.",
60
74
  "",
61
75
  "RULES:",
@@ -68,6 +82,20 @@ function grillingProtocol(topic, token) {
68
82
  "Required before commit: topic, title, decision, context (the rationale/why), alternatives_rejected. Missing any → keep grilling.",
69
83
  ].join("\n");
70
84
  }
85
+ /** Deterministic quality nudge for a freshly recorded ACCEPTED decision: an
86
+ * unattacked record (no rejected alternatives) or rejections without a
87
+ * "revisit if" flip condition get ONE advisory line — never a gate. */
88
+ function qualityNudge(rec) {
89
+ if (rec.status !== "accepted")
90
+ return "";
91
+ if (!rec.alternatives_rejected.length) {
92
+ return `\n\n△ Unattacked record: no alternatives_rejected. The graph can only veto what was explicitly rejected — next time run hunch_capture_decision(deciding:true) so rejections come from attacks that actually ran.`;
93
+ }
94
+ if (!rec.alternatives_rejected.some((a) => /revisit if/i.test(a))) {
95
+ return `\n\n△ Tip: none of the ${rec.alternatives_rejected.length} rejected alternative(s) carries a "revisit if …" flip condition — embed one per rejection so a future session knows when the call expires.`;
96
+ }
97
+ return "";
98
+ }
71
99
  /** Resolve a free-form target (symbol id / name / file path) to symbol records. */
72
100
  function resolveSymbols(store, target) {
73
101
  target = toPosixTarget(target);
@@ -359,10 +387,11 @@ export function buildServer(root) {
359
387
  inputSchema: {
360
388
  topic: z.string().optional().describe("proposed topic anchor (confirm with the human before committing)"),
361
389
  seed: z.string().optional().describe("what the decision is about, to focus the first question"),
390
+ deciding: z.boolean().optional().describe("the choice is NOT yet made — prepend the verdict loop (candidates → evidenced attacks → convergence → tripwires) so alternatives_rejected come from attacks that actually ran, then grill and record as usual"),
362
391
  },
363
- }, async ({ topic, seed }) => {
392
+ }, async ({ topic, seed, deciding }) => {
364
393
  const token = issueCaptureToken();
365
- return ok(`${grillingProtocol(topic, token)}${seed ? `\n\nSeed: ${seed}` : ""}`);
394
+ return ok(`${grillingProtocol(topic, token, !!deciding)}${seed ? `\n\nSeed: ${seed}` : ""}`);
366
395
  });
367
396
  // -- hunch_current_decision (decision-grounding: current(topic)) ----------
368
397
  server.registerTool("hunch_current_decision", {
@@ -448,31 +477,36 @@ export function buildServer(root) {
448
477
  provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
449
478
  date: now,
450
479
  };
480
+ // Where this write will actually land (see captureHome). Resolved BEFORE the
481
+ // uniqueness guard: in unified ("shared") mode home is the overlay even when
482
+ // private:false, so the guard must key its incumbent lookup on HOME, not on
483
+ // the flag — keying on the flag let a shared-mode supersede of a public
484
+ // incumbent pass the guard and then no-op the close (two live decisions).
485
+ const home = store.captureHome(!!decision.private);
451
486
  // Decision-grounding uniqueness guard (§4 Enforcement): never create a SECOND
452
487
  // live decision for one topic. Exclude ONLY the incumbent this write will
453
488
  // actually close — one resolvable in the SAME store the write lands in. A
454
- // cross-store supersede (public write vs a private incumbent, or vice-versa)
489
+ // cross-store supersede (the incumbent lives where this write can't close it)
455
490
  // would no-op and leave two live decisions, so it is treated as unresolved
456
491
  // (willClose=null) → the guard fires and refuses. Same-id re-record is allowed.
457
492
  if (rec.topic && rec.status === "accepted") {
458
- const willClose = decision.supersedes && store.decisionInStore(decision.supersedes, !!decision.private)
493
+ const willClose = decision.supersedes && store.decisionInStore(decision.supersedes, home === "private")
459
494
  ? decision.supersedes
460
495
  : null;
461
496
  const others = captureConflicts(store.recs("decisions"), rec.topic, id, willClose);
462
497
  if (others.length) {
463
498
  const list = others.map((d) => `${d.id} ("${d.title}")`).join(", ");
464
499
  const crossStore = decision.supersedes && !willClose
465
- ? ` (note: supersedes:"${decision.supersedes}" is not in the ${decision.private ? "private" : "public"} store, so it can't be closed from here)`
500
+ ? ` (note: supersedes:"${decision.supersedes}" is not in the ${home} store this write lands in, so it can't be closed from here)`
466
501
  : "";
467
502
  return err(`Topic "${rec.topic}" already has a live decision: ${list}.${crossStore} ` +
468
503
  `Hunch will not create a second current decision for one topic. Resolve it: ` +
469
504
  `re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
470
505
  }
471
506
  }
472
- // Route the write to its ONE home (captureHome): an explicit private:true goes to
473
- // the overlay (putPrivate throws rather than silently falling public); in unified
474
- // ("shared") mode EVERY capture goes to the overlay; else the public store.
475
- const home = store.captureHome(!!decision.private);
507
+ // Route the write to its ONE home: an explicit private:true goes to the overlay
508
+ // (putPrivate throws rather than silently falling public); in unified ("shared")
509
+ // mode EVERY capture goes to the overlay; else the public store.
476
510
  if (home === "private")
477
511
  store.putPrivate("decisions", rec);
478
512
  else
@@ -501,12 +535,15 @@ export function buildServer(root) {
501
535
  : capture_token
502
536
  ? ""
503
537
  : `\n\n⚠ Recorded WITHOUT a capture interview — the record stands, but harden it NOW in one exchange instead of switching flows: answer the first grilling question directly — "What alternative did you seriously consider and reject for '${rec.title.slice(0, 60)}', and what breaks if a future session re-introduces it?" — then fold the answer into alternatives_rejected via hunch_record_decision(supersedes: ${id}) or start the full interview with hunch_capture_decision. (A future major version will require a capture token here.)`;
538
+ // Quality nudge only when the untokened deprecation nudge isn't already
539
+ // grilling — one advisory voice per response, never two.
540
+ const quality = gated || capture_token ? qualityNudge(rec) : "";
504
541
  const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
505
542
  const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
506
543
  const where = decision.private
507
544
  ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
508
545
  : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
509
- return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}${captureNote}`);
546
+ return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}${captureNote}${quality}`);
510
547
  }
511
548
  catch (e) {
512
549
  return err(`Failed to record decision: ${e.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",