@davesheffer/hunch 0.14.2 → 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
@@ -258,6 +258,33 @@ or a blocking-linked regression — near-hits stay advisory, so it's safe as a m
258
258
  it before opening a PR (`{}` checks staged changes; pass `base: "origin/main"` for the range);
259
259
  the CI Constraint Guard renders the same cited verdict as a PR comment.
260
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
+
261
288
  ## Semantic search (optional)
262
289
 
263
290
  By default `hunch query` and the `hunch_query` MCP tool use fast keyword (FTS) search —
package/dist/cli/index.js CHANGED
@@ -37,7 +37,8 @@ import { scaffoldProviders } from "../integrations/providers.js";
37
37
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
38
38
  import { formatContext } from "../core/format.js";
39
39
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
40
- import { blockingInScope } from "../core/hookpolicy.js";
40
+ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
41
+ import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
41
42
  import { constraintId } from "../core/ids.js";
42
43
  import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
43
44
  import { mergeHunchJson } from "../store/merge.js";
@@ -555,7 +556,7 @@ program
555
556
  if (sources.length > 1)
556
557
  return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
557
558
  const markdown = opts.format === "markdown";
558
- 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 };
559
560
  const { store, root } = storeFor();
560
561
  // Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
561
562
  // branch) — otherwise the diff is empty and the guard passes vacuously.
@@ -594,6 +595,80 @@ program
594
595
  process.exitCode = 1;
595
596
  store.close();
596
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
+ });
597
672
  // ---- ci (scaffold the CI Constraint Guard) --------------------------------
598
673
  program
599
674
  .command("ci")
@@ -736,6 +811,16 @@ program
736
811
  emitDeny(deny.reason);
737
812
  return;
738
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
+ }
739
824
  }
740
825
  // advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
741
826
  const ctx = store.assembleContext(target);
@@ -777,10 +862,27 @@ program
777
862
  if (!d)
778
863
  return fail(`decision ${opts.accept} not found`);
779
864
  const source = d.provenance.source.includes("llm_draft") ? "llm_draft+human_confirmed" : "human_confirmed";
780
- 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 } });
781
882
  store.reindex();
782
883
  updateClaudeMd(root, store);
783
- 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})`);
784
886
  }
785
887
  else if (opts.reject) {
786
888
  const ok2 = store.json.delete("decisions", opts.reject);
@@ -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). */
@@ -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.2",
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.",