@davesheffer/hunch 0.14.1 → 0.15.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
@@ -85,6 +85,17 @@ Claude Code in the repo** afterward to pick up the `hunch_*` tools. (Each teamma
85
85
  `hunch init` once to wire up their own clone; the captured `.hunch/` content is shared
86
86
  via git.)
87
87
 
88
+ > **Prefer `hunch init` (project-local `.mcp.json`) over a global `claude mcp add`.**
89
+ > `.mcp.json` is registered by file path, so it's robust. A global `claude mcp add`
90
+ > writes to `~/.claude.json` keyed by the raw working-directory string — and **on
91
+ > Windows** that's a trap: drive letters are case-insensitive (`c:\` and `C:\` are the
92
+ > same folder) but Claude Code compares the key case-sensitively, so it can create two
93
+ > project blocks for one directory and a session that resolves to the *other* casing
94
+ > sees no `hunch_*` tools (registration looked fine, the tools just aren't there). If
95
+ > you hit this, run **`hunch doctor`** — on Windows it detects the split and heals it
96
+ > (merging the MCP servers across both casings, after backing up `~/.claude.json`).
97
+ > `hunch init` runs the same heal automatically at the end.
98
+
88
99
  ### 4. Use it
89
100
 
90
101
  ```bash
@@ -247,6 +258,33 @@ or a blocking-linked regression — near-hits stay advisory, so it's safe as a m
247
258
  it before opening a PR (`{}` checks staged changes; pass `base: "origin/main"` for the range);
248
259
  the CI Constraint Guard renders the same cited verdict as a PR comment.
249
260
 
261
+ ## Veto: re-introducing a *rejected* approach is blocked
262
+
263
+ The Regression Guard catches re-adding code a decision deliberately **retired**. But the most
264
+ expensive reversal is re-introducing an approach a decision **rejected** — the
265
+ `alternatives_rejected` that *never existed in code*, so a diff-only reviewer (and the regression
266
+ guard) is blind to it. A fresh session, not knowing, re-adds the very dependency you rejected for
267
+ latency last month.
268
+
269
+ **Veto** closes that gap. A decision can carry **tripwires** — machine-checkable signals (a
270
+ forbidden dependency, symbol, or scoped pattern) for an `alternatives_rejected` entry. When a diff
271
+ re-introduces one, Hunch blocks it with the receipt:
272
+
273
+ ```text
274
+ ⛔ VETO — this reverses dec_49916d02c9 ("Read-only layer over committed .hunch/ JSON").
275
+ You rejected: "extension queries MCP/API server for data" (adds latency, runtime coupling)
276
+ You chose: read directly from committed JSON, no backend dependency.
277
+ evidence: +import axios (vscode-extension/src/extension.ts)
278
+ ```
279
+
280
+ It rides the **same rails** as everything else: `CheckReport.vetoes` lights up `hunch check`, the CI
281
+ guard, and `hunch_merge_verdict` together, and the pre-edit hook denies the live edit (Edit / Write /
282
+ MultiEdit) *before* it's staged — so the agent self-corrects with no human in the loop. Enforcement
283
+ is **deterministic** (a set-intersection over a human-vouched record — no model in the block path)
284
+ and **progressive**: an auto-drafted tripwire only *warns*; `hunch veto backfill` drafts them and
285
+ `hunch review --accept` confirms a decision **and** its tripwires, flipping it from advisory to
286
+ blocking in one keypress. Full design + DX: [docs/veto.md](docs/veto.md).
287
+
250
288
  ## Semantic search (optional)
251
289
 
252
290
  By default `hunch query` and the `hunch_query` MCP tool use fast keyword (FTS) search —
package/dist/cli/index.js CHANGED
@@ -34,9 +34,11 @@ import { writeCiWorkflow } from "../integrations/ciAction.js";
34
34
  import { updateClaudeMd } from "../integrations/claudemd.js";
35
35
  import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
36
36
  import { scaffoldProviders } from "../integrations/providers.js";
37
+ import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
37
38
  import { formatContext } from "../core/format.js";
38
39
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
39
- import { blockingInScope } from "../core/hookpolicy.js";
40
+ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
41
+ import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
40
42
  import { constraintId } from "../core/ids.js";
41
43
  import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
42
44
  import { mergeHunchJson } from "../store/merge.js";
@@ -106,7 +108,10 @@ program
106
108
  console.log(" ⚠ not a git repo — skipped hooks (run `git init` to enable the learning loop)");
107
109
  }
108
110
  const mcp = writeMcpJson(root, inv.mcp);
109
- console.log(` ✓ wrote ${rel(root, mcp)} (registers the Hunch MCP server)`);
111
+ // .mcp.json is the CANONICAL registration: Claude Code resolves it by file path,
112
+ // so it's immune to the Windows ~/.claude.json drive-letter case-split that a
113
+ // global `claude mcp add` is prone to (see `hunch doctor`).
114
+ console.log(` ✓ wrote ${rel(root, mcp)} (registers the Hunch MCP server — canonical, path-keyed; prefer over a global \`claude mcp add\`)`);
110
115
  const cmds = writeSlashCommands(root);
111
116
  console.log(` ✓ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
112
117
  const cmd = updateClaudeMd(root, store);
@@ -131,6 +136,10 @@ program
131
136
  if (p.error)
132
137
  console.log(` ⚠ skipped ${p.assistant}: ${p.error}`);
133
138
  }
139
+ // Windows self-heal: if an earlier global `claude mcp add` left a drive-letter
140
+ // case-split in ~/.claude.json, merge it so hunch resolves under either casing.
141
+ // No-op (silent) off Windows.
142
+ reportClaudeConfigHeal();
134
143
  store.close();
135
144
  console.log("\nNext: make a commit (the hook captures a decision), then ask your coding assistant \"why is X built this way?\"");
136
145
  console.log("Cold start? Seed from history: hunch backfill --since 90d");
@@ -547,7 +556,7 @@ program
547
556
  if (sources.length > 1)
548
557
  return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
549
558
  const markdown = opts.format === "markdown";
550
- const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], strictBlockers: 0, regBlocking: 0 };
559
+ const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
551
560
  const { store, root } = storeFor();
552
561
  // Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
553
562
  // branch) — otherwise the diff is empty and the guard passes vacuously.
@@ -586,6 +595,80 @@ program
586
595
  process.exitCode = 1;
587
596
  store.close();
588
597
  });
598
+ // ---- veto (the rejected-alternative class, in isolation) ------------------
599
+ const vetoCmd = program
600
+ .command("veto")
601
+ .description("Decision Guard: flag changes that REVERSE a decision — re-introducing an approach an in-force decision rejected (the rejected-alternatives class, on its own).")
602
+ .option("--staged", "check git staged files (default)")
603
+ .option("--commit <sha>", "check a specific commit's files")
604
+ .option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main)")
605
+ .option("--strict", "exit non-zero on a human-confirmed, in-force, non-stale veto")
606
+ .option("--format <fmt>", "output: text (default) | markdown", "text")
607
+ .action((opts) => {
608
+ const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
609
+ if (sources.length > 1)
610
+ return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
611
+ const markdown = opts.format === "markdown";
612
+ const { store, root } = storeFor();
613
+ if (opts.base && !revExists(opts.base, root)) {
614
+ store.close();
615
+ return fail(`--base ref "${opts.base}" does not resolve. In CI, fetch the base branch first (git fetch origin <branch>).`);
616
+ }
617
+ store.reindex();
618
+ const files = opts.commit ? commitFiles(opts.commit, root)
619
+ : opts.base ? rangeFiles(opts.base, root)
620
+ : stagedFiles(root);
621
+ if (!files.length) {
622
+ console.log("No changed files to check.");
623
+ store.close();
624
+ return;
625
+ }
626
+ const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
627
+ const full = store.buildCheckReport(files, diff, { strict: !!opts.strict, lastChange: (f) => lastChangeDate(f, root) });
628
+ if (!full.vetoes.length) {
629
+ console.log(`✓ ${files.length} changed file(s) reverse no decision you rejected.`);
630
+ store.close();
631
+ return;
632
+ }
633
+ // Render ONLY the veto class — zero the other sections so the shared renderer
634
+ // shows just the rejected-alternative reversals (the rest is `hunch check`).
635
+ const vetoOnly = { ...full, direct: [], near: [], regressions: [], strictBlockers: 0, regBlocking: 0 };
636
+ console.log(markdown ? renderMarkdown(vetoOnly) : renderText(vetoOnly));
637
+ if (reportFailsStrict(vetoOnly))
638
+ process.exitCode = 1;
639
+ store.close();
640
+ });
641
+ vetoCmd
642
+ .command("backfill")
643
+ .description("Draft machine-checkable tripwires for existing rejected alternatives. Drafts are ADVISORY — confirm with `hunch review --accept <id>` to enable blocking.")
644
+ .action(() => {
645
+ const { store, root } = storeFor();
646
+ const knownDeps = knownRepoDeps(root);
647
+ let drafted = 0;
648
+ let touched = 0;
649
+ for (const d of store.json.loadAll("decisions")) {
650
+ if (d.superseded_by || d.status === "superseded")
651
+ continue;
652
+ if (!d.alternatives_rejected.length)
653
+ continue;
654
+ if ((d.rejected_tripwires?.length ?? 0) > 0)
655
+ continue; // never clobber existing tripwires
656
+ const tws = draftTripwires(d.alternatives_rejected, d.related_files, knownDeps);
657
+ store.json.put("decisions", { ...d, rejected_tripwires: tws });
658
+ drafted += tws.length;
659
+ touched++;
660
+ }
661
+ store.reindex();
662
+ if (!touched) {
663
+ console.log("✓ Nothing to backfill — every in-force decision with rejected alternatives already has tripwires.");
664
+ }
665
+ else {
666
+ console.log(`✓ Drafted ${drafted} tripwire(s) across ${touched} decision(s) — all ADVISORY (llm_draft).`);
667
+ console.log(" They warn but never block until confirmed. Confirm a decision's tripwires:");
668
+ console.log(" hunch review --accept <decision-id>");
669
+ }
670
+ store.close();
671
+ });
589
672
  // ---- ci (scaffold the CI Constraint Guard) --------------------------------
590
673
  program
591
674
  .command("ci")
@@ -728,6 +811,16 @@ program
728
811
  emitDeny(deny.reason);
729
812
  return;
730
813
  }
814
+ // Veto Guard (live): the proposed edit text re-introduces an approach an
815
+ // in-force decision REJECTED. Covers Edit (new_string), Write (content), and
816
+ // MultiEdit (edits[].new_string). The agent self-corrects before staging;
817
+ // only human-confirmed tripwires deny.
818
+ const proposedLines = proposedEditLines(evt.tool_input);
819
+ const vetoDeny = proposedLines.length ? vetoInScope(store, target, proposedLines) : null;
820
+ if (vetoDeny) {
821
+ emitDeny(vetoDeny.reason);
822
+ return;
823
+ }
731
824
  }
732
825
  // advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
733
826
  const ctx = store.assembleContext(target);
@@ -769,10 +862,27 @@ program
769
862
  if (!d)
770
863
  return fail(`decision ${opts.accept} not found`);
771
864
  const source = d.provenance.source.includes("llm_draft") ? "llm_draft+human_confirmed" : "human_confirmed";
772
- store.json.put("decisions", { ...d, status: "accepted", provenance: { ...d.provenance, source, confidence: 0.95, last_verified: new Date().toISOString() } });
865
+ const now = new Date().toISOString();
866
+ // Accepting a decision also CONFIRMS its drafted tripwires — this is how the
867
+ // Veto Guard goes from advisory to blocking ("confirm rides hunch review").
868
+ const tws = d.rejected_tripwires ?? [];
869
+ const confirmedTws = tws.map((tw) => ({
870
+ ...tw,
871
+ provenance: {
872
+ ...tw.provenance,
873
+ source: tw.provenance.source.includes("human_confirmed")
874
+ ? tw.provenance.source
875
+ : tw.provenance.source.includes("llm_draft")
876
+ ? "llm_draft+human_confirmed"
877
+ : "human_confirmed",
878
+ last_verified: now,
879
+ },
880
+ }));
881
+ store.json.put("decisions", { ...d, status: "accepted", rejected_tripwires: confirmedTws, provenance: { ...d.provenance, source, confidence: 0.95, last_verified: now } });
773
882
  store.reindex();
774
883
  updateClaudeMd(root, store);
775
- console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95)`);
884
+ const twNote = confirmedTws.length ? `, ${confirmedTws.length} tripwire(s) now blocking` : "";
885
+ console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${twNote})`);
776
886
  }
777
887
  else if (opts.reject) {
778
888
  const ok2 = store.json.delete("decisions", opts.reject);
@@ -940,11 +1050,37 @@ program
940
1050
  else {
941
1051
  console.log(dim(`semantic: off (keyword search only) — enable: npm i -g @huggingface/transformers && hunch embed`));
942
1052
  }
1053
+ // Windows: detect/heal the Claude Code ~/.claude.json drive-letter case-split
1054
+ // that silently hides the hunch_* MCP tools. No-op (silent) off Windows.
1055
+ reportClaudeConfigHeal();
943
1056
  store.close();
944
1057
  });
945
1058
  function rel(root, p) {
946
1059
  return p.startsWith(root) ? p.slice(root.length + 1) : p;
947
1060
  }
1061
+ /** Run the Windows ~/.claude.json drive-letter case-split heal and print what it
1062
+ * did. Silent + no-op off Windows. A parse refusal is surfaced as a warning, never
1063
+ * thrown out of doctor/init (those commands must still complete). */
1064
+ function reportClaudeConfigHeal() {
1065
+ let res;
1066
+ try {
1067
+ res = healClaudeConfigCaseSplit();
1068
+ }
1069
+ catch (e) {
1070
+ console.log(` ⚠ Claude config: ${e.message}`);
1071
+ return;
1072
+ }
1073
+ if (!res.applicable)
1074
+ return; // non-Windows: the case-split bug can't occur
1075
+ if (!res.changed) {
1076
+ console.log(dim(`Claude config: no drive-letter project split (${res.file})`));
1077
+ return;
1078
+ }
1079
+ for (const g of res.groups) {
1080
+ console.log(`✓ healed Claude Code project case-split: mirrored [${g.servers.join(", ")}] across ${g.casings.join(" · ")}`);
1081
+ }
1082
+ console.log(dim(` ↳ backup: ${res.backup}`));
1083
+ }
948
1084
  function dim(s) {
949
1085
  return `\x1b[2m${s}\x1b[0m`;
950
1086
  }
@@ -4,18 +4,18 @@
4
4
  * then renders it as text (terminal, unchanged) or markdown (a PR comment posted
5
5
  * by the GitHub Action). The exit-code decision lives with the caller. */
6
6
  export function reportIsClean(r) {
7
- return r.direct.length === 0 && r.near.length === 0 && r.regressions.length === 0;
7
+ return r.direct.length === 0 && r.near.length === 0 && r.regressions.length === 0 && r.vetoes.length === 0;
8
8
  }
9
9
  /** True when --strict should FAIL the commit/PR. */
10
10
  export function reportFailsStrict(r) {
11
- return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0);
11
+ return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0 || r.vetoBlocking > 0);
12
12
  }
13
13
  const mark = (s) => (s === "blocking" ? "⛔" : s === "warning" ? "⚠" : "·");
14
14
  const clip = (s, n = 160) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
15
15
  /** The deterministic VERDICT for a merge: block (a hard gate fired), warn (touches
16
16
  * memory but nothing hard-blocks), or pass (touches no recorded memory at all). */
17
17
  export function verdict(r) {
18
- if (r.strictBlockers > 0 || r.regBlocking > 0)
18
+ if (r.strictBlockers > 0 || r.regBlocking > 0 || r.vetoBlocking > 0)
19
19
  return "block";
20
20
  return reportIsClean(r) ? "pass" : "warn";
21
21
  }
@@ -70,10 +70,17 @@ export function renderText(r) {
70
70
  out.push(` ${h.blocking ? "⛔" : "⚠"} re-adds ${h.kind} \`${h.name}\` — ${h.decision} removed it${h.blocking ? " (blocking-linked)" : ""}\n “${h.title}”\n ${h.reason}`);
71
71
  }
72
72
  }
73
+ if (r.vetoes.length) {
74
+ out.push(`${r.direct.length || r.near.length || r.regressions.length ? "\n" : ""}Reverses ${r.vetoes.length} decision(s) you rejected:\n`);
75
+ for (const v of r.vetoes) {
76
+ out.push(` ${v.blocking ? "⛔" : "⚠"} ${v.decision} rejected this approach${v.blocking ? " (human-confirmed)" : " (advisory)"}\n you rejected: ${clip(v.alternative)}\n you chose: ${clip(v.chosen)}\n evidence: ${v.evidence.slice(0, 4).join(", ")}`);
77
+ }
78
+ }
73
79
  if (reportFailsStrict(r)) {
74
80
  const reasons = [
75
81
  r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
76
82
  r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
83
+ r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
77
84
  ].filter(Boolean).join(" + ");
78
85
  out.push(`\n✗ ${reasons} — review before committing.`);
79
86
  }
@@ -125,11 +132,22 @@ export function renderMarkdown(r) {
125
132
  }
126
133
  out.push("");
127
134
  }
135
+ if (r.vetoes.length) {
136
+ out.push(`### ⛔ Reverses a decision you rejected`);
137
+ for (const v of r.vetoes) {
138
+ out.push(`- ${v.blocking ? "⛔" : "⚠"} \`${v.decision}\` rejected this approach${v.blocking ? " **(human-confirmed)**" : " _(advisory)_"}`);
139
+ out.push(` - you rejected: _${clip(v.alternative)}_`);
140
+ out.push(` - you chose: ${clip(v.chosen)}`);
141
+ out.push(` - evidence: ${v.evidence.slice(0, 4).map((e) => `\`${e}\``).join(", ")}`);
142
+ }
143
+ out.push("");
144
+ }
128
145
  out.push("---");
129
146
  if (reportFailsStrict(r)) {
130
147
  const reasons = [
131
148
  r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
132
149
  r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
150
+ r.vetoBlocking ? `${r.vetoBlocking} reversed-decision veto(es)` : "",
133
151
  ].filter(Boolean).join(" + ");
134
152
  out.push(`❌ **This PR breaks ${reasons}.** Resolve or supersede the decision before merge.`);
135
153
  }
@@ -19,4 +19,25 @@ export function blockingInScope(store, file) {
19
19
  }
20
20
  return null;
21
21
  }
22
+ /** Return a BlockingHit if the proposed added lines for `file` re-introduce an
23
+ * approach an in-force decision deliberately REJECTED (a blocking veto), else null.
24
+ * The deny text states ONLY the decision + receipt (what was rejected, what was
25
+ * chosen) — never how to supersede or disable the guard, so an autonomous agent
26
+ * cannot be coached into reversing a decision to land its edit (dec_a466655539). */
27
+ /** Flatten the proposed-edit text from a PreToolUse `tool_input` across all three
28
+ * edit tools — Edit (`new_string`), Write (`content`), MultiEdit (`edits[].new_string`)
29
+ * — into candidate added lines for the Veto Guard. Empty input → []. */
30
+ export function proposedEditLines(toolInput) {
31
+ const parts = [toolInput?.new_string, toolInput?.content, ...(toolInput?.edits ?? []).map((e) => e?.new_string)]
32
+ .filter((s) => !!s);
33
+ return parts.length ? parts.join("\n").split("\n") : [];
34
+ }
35
+ export function vetoInScope(store, file, proposedAddedLines) {
36
+ const hit = store.vetoForFileEdit(file, proposedAddedLines).find((v) => v.blocks);
37
+ if (!hit)
38
+ return null;
39
+ return {
40
+ reason: `Hunch: editing ${file} would REVERSE decision ${hit.decision} — you rejected "${hit.alternative}" and chose "${hit.chosen}". Do not re-introduce the rejected approach; preserve the chosen design.`,
41
+ };
42
+ }
22
43
  //# sourceMappingURL=hookpolicy.js.map
@@ -9,6 +9,12 @@
9
9
  * on a shared repo: a false positive downgrades to a warning instead of wrongly
10
10
  * failing a teammate's commit. */
11
11
  export const STRICT_MIN_CONFIDENCE = 0.8;
12
+ /** Is a provenance source HUMAN-CONFIRMED? Token-aware, so a composite source like
13
+ * "llm_draft+human_confirmed" counts, but a lookalike ("not_human_confirmed",
14
+ * "human_confirmed_pending") does not. The sources Hunch writes are "+"-joined. */
15
+ export function isHumanConfirmed(source) {
16
+ return (source ?? "").split("+").includes("human_confirmed");
17
+ }
12
18
  /** May this invariant FAIL a commit under --strict? Requires blocking severity,
13
19
  * a fresh (non-stale) record, and either high provenance confidence or a
14
20
  * human-confirmed source (a person vouched for it). Near/blast-radius hits never
@@ -19,6 +25,21 @@ export function isStrictBlocker(c, stale) {
19
25
  if (stale)
20
26
  return false;
21
27
  const confidence = c.provenance?.confidence ?? 0;
22
- return confidence >= STRICT_MIN_CONFIDENCE || c.provenance?.source === "human_confirmed";
28
+ return confidence >= STRICT_MIN_CONFIDENCE || isHumanConfirmed(c.provenance?.source);
29
+ }
30
+ /** May a VETO fail a commit? Sibling of isStrictBlocker, but keys on the TRIPWIRE's
31
+ * trust — not the rejecting decision's — and uses ONE rule for every tier: only a
32
+ * `human_confirmed` tripwire blocks. An llm_draft tripwire never blocks regardless
33
+ * of confidence (an LLM self-score is not a licence to fail a commit); it only
34
+ * warns. Semantic similarity never blocks. In-force + non-stale gates run first.
35
+ * This is what makes the "day-one is advisory" DX true (dec_a466655539). */
36
+ export function isVetoBlocker(d, tw, tier, stale) {
37
+ if (d.status === "superseded" || d.superseded_by)
38
+ return false; // in-force decisions only
39
+ if (stale)
40
+ return false; // freshness gate
41
+ if (tier === "semantic")
42
+ return false; // never block on similarity
43
+ return isHumanConfirmed(tw.provenance?.source); // confirmed ⇒ blocks, any tier
23
44
  }
24
45
  //# sourceMappingURL=strictgate.js.map
@@ -74,6 +74,25 @@ export const RetiredSignalSchema = z.object({
74
74
  symbols: z.array(z.string()).default([]).describe("symbol names this decision removed"),
75
75
  deps: z.array(z.string()).default([]).describe("external deps this decision dropped"),
76
76
  });
77
+ /** A machine-checkable signal for a REJECTED alternative (the Veto Guard). Unlike
78
+ * `retired` (code that once existed and was removed), a rejected alternative never
79
+ * existed in code, so its prose is turned into a testable set/regex. Carries its
80
+ * OWN provenance, separate from the decision's: an LLM may DRAFT a tripwire
81
+ * (advisory only); only a `human_confirmed` tripwire may BLOCK a commit — for every
82
+ * tier. One predictable rule (dec_a466655539). See docs/veto.md. */
83
+ export const RejectedTripwireSchema = z.object({
84
+ alternative: z.string().describe("the rejected approach's human text — printed verbatim in the receipt"),
85
+ scope: z.array(z.string()).default([]).describe("glob(s) it applies to, e.g. vscode-extension/**"),
86
+ forbids: z
87
+ .object({
88
+ deps: z.array(z.string()).default([]).describe("external imports that signal the rejected approach"),
89
+ symbols: z.array(z.string()).default([]).describe("identifier names that signal it"),
90
+ patterns: z.array(z.string()).default([]).describe("scoped line regexes (last resort)"),
91
+ })
92
+ .default({ deps: [], symbols: [], patterns: [] }),
93
+ embed_ref: z.string().optional().describe("optional handle into embeddings for the advisory semantic tier"),
94
+ provenance: ProvenanceSchema,
95
+ });
77
96
  /** ADR-style decision record, auto-drafted and human-confirmable. */
78
97
  export const DecisionSchema = z.object({
79
98
  id: z.string().describe("dec_*"),
@@ -83,6 +102,7 @@ export const DecisionSchema = z.object({
83
102
  decision: z.string().default(""),
84
103
  consequences: z.array(z.string()).default([]),
85
104
  alternatives_rejected: z.array(z.string()).default([]),
105
+ rejected_tripwires: z.array(RejectedTripwireSchema).default([]).describe("machine-checkable signals for alternatives_rejected (Veto Guard)"),
86
106
  related_components: z.array(z.string()).default([]),
87
107
  related_files: z.array(z.string()).default([]),
88
108
  supersedes: z.string().nullable().default(null),
@@ -45,6 +45,7 @@ export function analyzeDiff(diff) {
45
45
  const perFile = new Map();
46
46
  const addedImports = new Set();
47
47
  const removedImports = new Set();
48
+ const addedLinesBy = new Map();
48
49
  let addedLines = 0;
49
50
  let removedLines = 0;
50
51
  let curFile = "";
@@ -120,6 +121,12 @@ export function analyzeDiff(diff) {
120
121
  if (!curAdded && !curDeleted)
121
122
  filesModified.add(curFile);
122
123
  const body = raw.slice(1);
124
+ let lines = addedLinesBy.get(curFile);
125
+ if (!lines) {
126
+ lines = [];
127
+ addedLinesBy.set(curFile, lines);
128
+ }
129
+ lines.push(body);
123
130
  const d = declOf(body);
124
131
  if (d)
125
132
  declsFor(curFile)?.added.set(d.name, d);
@@ -171,6 +178,7 @@ export function analyzeDiff(diff) {
171
178
  removedDeps: [...removedImports].filter((d) => !addedImports.has(d)),
172
179
  addedLines,
173
180
  removedLines,
181
+ addedLinesByFile: addedLinesBy,
174
182
  };
175
183
  }
176
184
  /** A compact human-readable summary of a DiffAnalysis (used in decision text). */
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Heal a Windows-only Claude Code misconfiguration that silently hides Hunch's
3
+ * `hunch_*` MCP tools.
4
+ *
5
+ * THE BUG (Claude Code's, not Hunch's): Claude Code stores per-project config in
6
+ * `~/.claude.json` under a `projects` map keyed by the raw cwd STRING. Windows
7
+ * drive letters are case-insensitive (`c:\` and `C:\` are the same directory) but
8
+ * Claude Code compares the key case-sensitively. So it can create TWO project
9
+ * blocks for one real directory:
10
+ *
11
+ * "c:/Users/me/repo" -> mcpServers: {} (what one session resolves to)
12
+ * "C:/Users/me/repo" -> mcpServers: { hunch: {…} } (where `claude mcp add` wrote)
13
+ *
14
+ * A session whose cwd resolves to the OTHER casing reads the empty block → no
15
+ * hunch tools, even though registration "succeeded".
16
+ *
17
+ * Only the GLOBAL `claude mcp add` route (cwd-string-keyed in ~/.claude.json) is
18
+ * fragile. Hunch's own project-local `.mcp.json` (scaffold.ts writeMcpJson) is
19
+ * IMMUNE — Claude resolves it by file path, not by a cwd string key.
20
+ */
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { existsSync, readFileSync, copyFileSync } from "node:fs";
24
+ import { writeFileAtomic } from "../core/io.js";
25
+ /** Absolute path to Claude Code's per-user config (`~/.claude.json`). */
26
+ export function claudeConfigPath() {
27
+ return join(homedir(), ".claude.json");
28
+ }
29
+ /** Group key for two project keys that point at the SAME real directory. The bug
30
+ * is purely drive-letter case (+ slash style), so we normalize ONLY those — never
31
+ * the rest of the path — so genuinely distinct projects are never merged. */
32
+ function normalizeProjectKey(key) {
33
+ return key.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_m, d) => `${d.toLowerCase()}:`);
34
+ }
35
+ function isPlainObject(v) {
36
+ return !!v && typeof v === "object" && !Array.isArray(v);
37
+ }
38
+ function asStringArray(v) {
39
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
40
+ }
41
+ /** Union the MCP config across all casing variants of one real project. First-wins
42
+ * on a server-name collision (keys iterated in sorted order for determinism) so we
43
+ * never clobber an existing server definition; enabled/disabled lists are deduped
44
+ * unions. */
45
+ function unionConfig(blocks) {
46
+ const servers = {};
47
+ const enabled = new Set();
48
+ const disabled = new Set();
49
+ for (const b of blocks) {
50
+ if (isPlainObject(b.mcpServers)) {
51
+ for (const [name, cfg] of Object.entries(b.mcpServers))
52
+ if (!(name in servers))
53
+ servers[name] = cfg;
54
+ }
55
+ for (const s of asStringArray(b.enabledMcpjsonServers))
56
+ enabled.add(s);
57
+ for (const s of asStringArray(b.disabledMcpjsonServers))
58
+ disabled.add(s);
59
+ }
60
+ return { servers, enabled: [...enabled], disabled: [...disabled] };
61
+ }
62
+ /** Mirror the union into one casing block, touching ONLY the three MCP keys and
63
+ * ADDING missing entries (never overwriting an existing one). Returns true if the
64
+ * block changed. */
65
+ function applyUnion(block, u) {
66
+ let changed = false;
67
+ if (!isPlainObject(block.mcpServers)) {
68
+ block.mcpServers = {};
69
+ if (Object.keys(u.servers).length)
70
+ changed = true;
71
+ }
72
+ for (const [name, cfg] of Object.entries(u.servers)) {
73
+ if (!(name in block.mcpServers)) {
74
+ block.mcpServers[name] = cfg;
75
+ changed = true;
76
+ }
77
+ }
78
+ const mergeList = (key, extra) => {
79
+ if (!extra.length)
80
+ return;
81
+ const cur = asStringArray(block[key]);
82
+ const merged = [...new Set([...cur, ...extra])];
83
+ if (merged.length !== cur.length) {
84
+ block[key] = merged;
85
+ changed = true;
86
+ }
87
+ };
88
+ mergeList("enabledMcpjsonServers", u.enabled);
89
+ mergeList("disabledMcpjsonServers", u.disabled);
90
+ return changed;
91
+ }
92
+ /** Windows-safe timestamp for the backup filename (no `:` — invalid on NTFS). */
93
+ function backupStamp() {
94
+ return new Date().toISOString().replace(/[:.]/g, "-");
95
+ }
96
+ /**
97
+ * Scan `~/.claude.json` for project keys that collapse to the same real directory
98
+ * but differ by drive-letter case, and HEAL each split by computing the UNION of
99
+ * its casings' MCP config and MIRRORING that union back into EVERY casing.
100
+ *
101
+ * Why mirror (not merge-into-one-canonical-and-delete-the-rest): we cannot predict
102
+ * which casing a given Claude Code session will resolve its cwd to. If we collapsed
103
+ * to a single canonical key, a session that lands on a deleted casing would get a
104
+ * fresh empty block → hunch missing again. Mirroring the union guarantees that
105
+ * whichever casing wins, the server is there — and it deletes nothing Claude made.
106
+ *
107
+ * Safety: no-op on non-Windows; backs up the file (timestamped copy) BEFORE any
108
+ * write; merges only (never clobbers other servers/keys); and THROWS rather than
109
+ * overwrite a non-empty file it cannot parse (mirrors readJsonObj in providers.ts).
110
+ */
111
+ export function healClaudeConfigCaseSplit(opts = {}) {
112
+ const platform = opts.platform ?? process.platform;
113
+ const file = opts.file ?? claudeConfigPath();
114
+ const base = { platform, applicable: platform === "win32", file, changed: false, groups: [] };
115
+ if (platform !== "win32")
116
+ return base; // the case-split bug is Windows-only
117
+ if (!existsSync(file))
118
+ return base;
119
+ const raw = readFileSync(file, "utf8");
120
+ if (!raw.trim())
121
+ return base;
122
+ let root;
123
+ try {
124
+ const v = JSON.parse(raw);
125
+ if (!isPlainObject(v))
126
+ throw new Error("not a JSON object");
127
+ root = v;
128
+ }
129
+ catch (e) {
130
+ throw new Error(`refusing to modify ${file}: could not parse it (${e.message}). Fix or remove it, then re-run.`);
131
+ }
132
+ const projects = root.projects;
133
+ if (!isPlainObject(projects))
134
+ return base; // nothing to heal
135
+ // Bucket the raw project keys by their normalized real path.
136
+ const buckets = new Map();
137
+ for (const key of Object.keys(projects)) {
138
+ const norm = normalizeProjectKey(key);
139
+ const arr = buckets.get(norm) ?? [];
140
+ arr.push(key);
141
+ buckets.set(norm, arr);
142
+ }
143
+ const groups = [];
144
+ let changed = false;
145
+ for (const [norm, keys] of buckets) {
146
+ if (keys.length < 2)
147
+ continue; // no casing split for this directory
148
+ keys.sort(); // deterministic first-wins union
149
+ const blocks = keys.map((k) => (isPlainObject(projects[k]) ? projects[k] : {}));
150
+ const u = unionConfig(blocks);
151
+ let groupChanged = false;
152
+ for (const k of keys) {
153
+ if (!isPlainObject(projects[k]))
154
+ projects[k] = {};
155
+ if (applyUnion(projects[k], u))
156
+ groupChanged = true;
157
+ }
158
+ if (groupChanged) {
159
+ changed = true;
160
+ groups.push({ realPath: norm, casings: keys, servers: Object.keys(u.servers) });
161
+ }
162
+ }
163
+ if (!changed)
164
+ return { ...base, groups };
165
+ // Back up the exact original bytes BEFORE writing the healed config.
166
+ const backup = `${file}.hunch-bak-${backupStamp()}`;
167
+ copyFileSync(file, backup);
168
+ writeFileAtomic(file, JSON.stringify(root, null, 2) + "\n");
169
+ return { ...base, changed: true, backup, groups };
170
+ }
171
+ //# sourceMappingURL=claudeConfig.js.map
@@ -30,6 +30,12 @@ export function ensureGitignore(root) {
30
30
  const cur = readFileSync(path, "utf8");
31
31
  if (cur.includes(MARK))
32
32
  return { path, action: "unchanged" }; // already managed
33
+ // Already covered by the user's OWN entries (e.g. a hand-written, commented
34
+ // section listing the same patterns)? Don't append a redundant managed block —
35
+ // that would leave two copies of every ignore. Keep the .gitignore clean.
36
+ const lines = new Set(cur.split("\n").map((l) => l.trim()));
37
+ if (ENTRIES.every((e) => lines.has(e)))
38
+ return { path, action: "unchanged" };
33
39
  const sep = cur.endsWith("\n") || cur.length === 0 ? "" : "\n";
34
40
  writeFileSync(path, `${cur}${sep}${block}\n`);
35
41
  return { path, action: "appended" };
@@ -263,6 +263,7 @@ export function buildServer(root) {
263
263
  decision: decision.decision ?? existing?.decision ?? "",
264
264
  consequences: decision.consequences ?? [],
265
265
  alternatives_rejected: decision.alternatives_rejected ?? [],
266
+ rejected_tripwires: existing?.rejected_tripwires ?? [], // preserve confirmed tripwires across re-record
266
267
  related_components: decision.related_components ?? existing?.related_components ?? [],
267
268
  related_files: (decision.related_files ?? existing?.related_files ?? []).map(toPosixTarget),
268
269
  supersedes: decision.supersedes ?? existing?.supersedes ?? null,
@@ -18,7 +18,7 @@ import { selectEmbedder } from "./embedder.js";
18
18
  import { JsonStore } from "./jsonStore.js";
19
19
  import { pathMatchesGlob } from "../core/glob.js";
20
20
  import { edgeId } from "../core/ids.js";
21
- import { isStrictBlocker } from "../core/strictgate.js";
21
+ import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
22
22
  import { analyzeDiff } from "../extractors/diff.js";
23
23
  export class HunchStore {
24
24
  paths;
@@ -439,9 +439,10 @@ export class HunchStore {
439
439
  }
440
440
  const an = analyzeDiff(diff);
441
441
  const regHits = this.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
442
- const staleIds = opts.strict && opts.lastChange
443
- ? new Set(this.staleness(opts.lastChange).filter((s) => s.kind === "constraint").map((s) => s.id))
444
- : new Set();
442
+ const staleRecords = opts.strict && opts.lastChange ? this.staleness(opts.lastChange) : [];
443
+ const staleIds = new Set(staleRecords.filter((s) => s.kind === "constraint").map((s) => s.id));
444
+ const staleDecisionIds = new Set(staleRecords.filter((s) => s.kind === "decision").map((s) => s.id));
445
+ const vetoes = this.vetoHits(an, files, staleDecisionIds);
445
446
  const directReport = [...direct.values()].map(({ c, files: fs }) => {
446
447
  const stale = staleIds.has(c.id);
447
448
  const strictBlocks = isStrictBlocker(c, stale);
@@ -458,8 +459,10 @@ export class HunchStore {
458
459
  direct: directReport,
459
460
  near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
460
461
  regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
462
+ vetoes: vetoes.map((v) => ({ decision: v.decision, title: v.title, alternative: v.alternative, chosen: v.chosen, tier: v.tier, evidence: v.evidence, blocking: v.blocks })),
461
463
  strictBlockers: directReport.filter((d) => d.strictBlocks).length,
462
464
  regBlocking: regHits.filter((h) => h.blocking).length,
465
+ vetoBlocking: vetoes.filter((v) => v.blocks).length,
463
466
  };
464
467
  }
465
468
  /** Time-travel: the decision history for a target — every decision touching it,
@@ -542,6 +545,82 @@ export class HunchStore {
542
545
  }
543
546
  return out;
544
547
  }
548
+ /** Veto Guard: detect a change RE-INTRODUCING an approach an in-force decision
549
+ * deliberately REJECTED (`decision.rejected_tripwires`). The counterpart to
550
+ * regressionHits, which only sees code that once existed — a rejected alternative
551
+ * never did. Precision-first ladder (dep > symbol > pattern); the semantic tier is
552
+ * advisory and lives elsewhere. A hit `blocks` only when isVetoBlocker passes (a
553
+ * human-confirmed tripwire on an in-force, non-stale decision — dec_a466655539).
554
+ * Read-only; shared by buildCheckReport. See docs/veto.md. */
555
+ vetoHits(an, files, staleDecisions = new Set()) {
556
+ const addedDeps = new Set(an.addedDeps);
557
+ const out = [];
558
+ const seen = new Set(); // dedup: one hit per decision+alternative
559
+ for (const d of this.json.loadAll("decisions")) {
560
+ if (d.superseded_by || d.status === "superseded")
561
+ continue; // in-force only
562
+ const tripwires = d.rejected_tripwires ?? [];
563
+ if (!tripwires.length)
564
+ continue;
565
+ const stale = staleDecisions.has(d.id);
566
+ for (const tw of tripwires) {
567
+ // scope: a tripwire with globs must intersect the touched files; scopeless = any
568
+ const scopedFiles = tw.scope.length
569
+ ? files.filter((f) => tw.scope.some((g) => pathMatchesGlob(f, g)))
570
+ : files;
571
+ if (tw.scope.length && !scopedFiles.length)
572
+ continue;
573
+ // added line bodies within the scoped files (call sites, not just decls)
574
+ const scopedAdded = [];
575
+ for (const f of scopedFiles) {
576
+ const lines = an.addedLinesByFile.get(f);
577
+ if (lines)
578
+ scopedAdded.push(...lines);
579
+ }
580
+ const match = matchTripwire(tw, addedDeps, scopedAdded);
581
+ if (!match)
582
+ continue;
583
+ const key = `${d.id}::${tw.alternative}`;
584
+ if (seen.has(key))
585
+ continue;
586
+ seen.add(key);
587
+ out.push({
588
+ decision: d.id,
589
+ title: d.title,
590
+ alternative: tw.alternative,
591
+ chosen: d.decision || d.title,
592
+ tier: match.tier,
593
+ evidence: [...match.evidence, ...scopedFiles.slice(0, 3)],
594
+ blocks: isVetoBlocker(d, tw, match.tier, stale),
595
+ why: d.caused_by_bug ? this.vetoWhy(d.caused_by_bug) : undefined,
596
+ });
597
+ }
598
+ }
599
+ return out;
600
+ }
601
+ /** Resolve a veto's causal citation: the bug whose root cause spawned the decision
602
+ * (decision → caused_by_bug). Distinct from causalChain, which is constraint-keyed. */
603
+ vetoWhy(bugId) {
604
+ const bug = this.json.get("bugs", bugId);
605
+ return bug ? { bug: { id: bug.id, title: bug.title, root_cause: bug.root_cause } } : undefined;
606
+ }
607
+ /** Veto check for a LIVE edit (the agent pre-edit hook): no diff exists yet, so
608
+ * synthesize a minimal added-only diff from the proposed new lines and run the
609
+ * same vetoHits ladder. Freshness needs git lastChange (unavailable at edit time),
610
+ * so the staleness gate is skipped here — the commit/CI path applies it. */
611
+ vetoForFileEdit(file, addedLines) {
612
+ if (!addedLines.length)
613
+ return [];
614
+ const f = toPosixTarget(file);
615
+ const synthetic = [
616
+ `diff --git a/${f} b/${f}`,
617
+ `--- a/${f}`,
618
+ `+++ b/${f}`,
619
+ `@@ -0,0 +1,${addedLines.length} @@`,
620
+ ...addedLines.map((l) => `+${l}`),
621
+ ].join("\n");
622
+ return this.vetoHits(analyzeDiff(synthetic), [f]);
623
+ }
545
624
  /** The symbols/deps an in-force decision deliberately RETIRED from a file — the
546
625
  * agent-hook grounding ("don't re-add X here; dec_Y removed it"). No diff is
547
626
  * available at edit time, so this surfaces the risk as context, not a block. */
@@ -669,6 +748,49 @@ export class HunchStore {
669
748
  return ctx;
670
749
  }
671
750
  }
751
+ /** Walk a tripwire's precision-first ladder against an analyzed diff. dep (exact
752
+ * set intersection) > symbol (whole-word identifier in an added line) > pattern
753
+ * (scoped regex). Returns the highest-precision match, or null. Bad user/LLM regex
754
+ * is compiled defensively and never throws (a malformed pattern is simply inert). */
755
+ function matchTripwire(tw, addedDeps, scopedAdded) {
756
+ // dep tier: the forbidden dep must be a genuinely-new external import (addedDeps)
757
+ // AND imported in a SCOPED added line — not merely added somewhere else in the
758
+ // diff. Without the scoped check, axios added in an out-of-scope file plus any
759
+ // edit to an in-scope file would false-positive.
760
+ const hitDeps = tw.forbids.deps.filter((dep) => addedDeps.has(dep) && scopedAdded.some((l) => importsDep(l, dep)));
761
+ if (hitDeps.length)
762
+ return { tier: "dep", evidence: hitDeps.map((d) => `+import ${d}`) };
763
+ const hitSyms = tw.forbids.symbols.filter((s) => {
764
+ const re = new RegExp(`\\b${escapeRe(s)}\\b`);
765
+ return scopedAdded.some((l) => re.test(l));
766
+ });
767
+ if (hitSyms.length)
768
+ return { tier: "symbol", evidence: hitSyms.map((s) => `+${s}`) };
769
+ for (const p of tw.forbids.patterns) {
770
+ let re = null;
771
+ try {
772
+ re = new RegExp(p);
773
+ }
774
+ catch {
775
+ re = null; // malformed pattern is inert, never a thrown error in the gate
776
+ }
777
+ if (!re)
778
+ continue;
779
+ const hit = scopedAdded.find((l) => re.test(l));
780
+ if (hit)
781
+ return { tier: "pattern", evidence: [`/${p}/ matched: ${hit.trim().slice(0, 80)}`] };
782
+ }
783
+ return null;
784
+ }
785
+ function escapeRe(s) {
786
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
787
+ }
788
+ /** Does an added line actually IMPORT `dep` (not merely mention it in a string)?
789
+ * Covers `import x from "dep"`, `import "dep"`, `} from "dep"`, `require("dep")` —
790
+ * so a literal like `const m = "axios"` no longer trips the dep tier. */
791
+ function importsDep(line, dep) {
792
+ return new RegExp(`(?:from|import|require\\(?)\\s*['"]${escapeRe(dep)}['"]`).test(line);
793
+ }
672
794
  function sev(s) {
673
795
  return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
674
796
  }
@@ -3,6 +3,7 @@ import { analyzeDiff } from "../extractors/diff.js";
3
3
  import { selectProvider, DeterministicProvider } from "./provider.js";
4
4
  import { decisionId, bugId, constraintId } from "../core/ids.js";
5
5
  import { pathMatchesGlob } from "../core/glob.js";
6
+ import { draftTripwires, knownRepoDeps } from "./tripwires.js";
6
7
  const CODE_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
7
8
  const SKIP_SUBJECT = /^(merge|revert|bump|chore\(deps\)|format|lint|wip)\b/i;
8
9
  // Below this many changed code lines, a commit with no structural change and no
@@ -90,6 +91,12 @@ export async function syncCommit(store, root, sha, opts = {}) {
90
91
  decision: draft.decision,
91
92
  consequences: draft.consequences,
92
93
  alternatives_rejected: draft.alternatives_rejected,
94
+ // Capture-time drafting: scaffold ADVISORY tripwires from the rejected
95
+ // alternatives (preserve any already-curated ones across re-sync). All llm_draft
96
+ // → never block until confirmed via `hunch review --accept` (dec_a466655539).
97
+ rejected_tripwires: existing?.rejected_tripwires?.length
98
+ ? existing.rejected_tripwires
99
+ : draftTripwires(draft.alternatives_rejected, codeFiles, knownRepoDeps(root)),
93
100
  related_components: relatedComponents,
94
101
  related_files: codeFiles,
95
102
  supersedes: existing?.supersedes ?? null,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Deterministic DRAFT-tripwire scaffolding for the Veto Guard (tier 6). Both the
3
+ * capture path (synthesize.ts) and `hunch veto backfill` use this to turn a
4
+ * decision's `alternatives_rejected` prose into machine-checkable tripwires —
5
+ * always `llm_draft`, so they are ADVISORY ONLY until a human confirms them
6
+ * (dec_a466655539). The LLM may later enrich the same shape; this is the no-LLM
7
+ * floor that keeps the feature useful offline.
8
+ */
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ /** Scaffold one draft tripwire per rejected alternative. Scope = directory globs of
11
+ * the decision's related files. `forbids` is best-effort from the prose: known repo
12
+ * dependencies named in the text, plus backticked identifiers as candidate symbols.
13
+ * Empty `forbids` is fine — the tripwire is then inert until a human fills it in. */
14
+ export function draftTripwires(alternatives, relatedFiles, knownDeps) {
15
+ const scope = dirGlobs(relatedFiles);
16
+ return alternatives.map((alt) => ({
17
+ alternative: alt,
18
+ scope,
19
+ forbids: {
20
+ deps: knownDeps.filter((dep) => mentions(alt, dep)),
21
+ symbols: [...alt.matchAll(/`([A-Za-z_$][\w$]*)`/g)].map((m) => m[1]),
22
+ patterns: [],
23
+ },
24
+ provenance: { source: "llm_draft", confidence: 0.5, evidence: [] },
25
+ }));
26
+ }
27
+ /** External dependency names declared in the repo's package.json (every section),
28
+ * used to recognise a dep named in a rejected-alternative sentence. */
29
+ export function knownRepoDeps(root) {
30
+ const p = `${root}/package.json`;
31
+ if (!existsSync(p))
32
+ return [];
33
+ try {
34
+ const pkg = JSON.parse(readFileSync(p, "utf8"));
35
+ return Object.keys({
36
+ ...pkg.dependencies,
37
+ ...pkg.devDependencies,
38
+ ...pkg.peerDependencies,
39
+ ...pkg.optionalDependencies,
40
+ });
41
+ }
42
+ catch {
43
+ return []; // unparseable package.json → no auto-deps, never throw
44
+ }
45
+ }
46
+ /** Whole-token match of a dep name in prose (handles scoped/hyphenated names like
47
+ * `node-fetch` or `@scope/pkg` without matching inside a larger word). */
48
+ function mentions(text, dep) {
49
+ return new RegExp(`(^|[^\\w@/-])${escapeRe(dep)}([^\\w@/-]|$)`).test(text);
50
+ }
51
+ function dirGlobs(files) {
52
+ const set = new Set();
53
+ for (const f of files) {
54
+ const i = f.lastIndexOf("/");
55
+ if (i > 0)
56
+ set.add(`${f.slice(0, i)}/**`);
57
+ }
58
+ return [...set];
59
+ }
60
+ function escapeRe(s) {
61
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62
+ }
63
+ //# sourceMappingURL=tripwires.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.14.1",
3
+ "version": "0.15.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.",