@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 +27 -0
- package/dist/cli/index.js +106 -4
- package/dist/core/checkreport.js +21 -3
- package/dist/core/hookpolicy.js +21 -0
- package/dist/core/strictgate.js +22 -1
- package/dist/core/types.js +20 -0
- package/dist/extractors/diff.js +8 -0
- package/dist/integrations/gitignore.js +6 -0
- package/dist/mcp/server.js +1 -0
- package/dist/store/hunchStore.js +126 -4
- package/dist/synthesis/synthesize.js +7 -0
- package/dist/synthesis/tripwires.js +63 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/core/checkreport.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/hookpolicy.js
CHANGED
|
@@ -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
|
package/dist/core/strictgate.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/core/types.js
CHANGED
|
@@ -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),
|
package/dist/extractors/diff.js
CHANGED
|
@@ -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" };
|
package/dist/mcp/server.js
CHANGED
|
@@ -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,
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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
|
|
443
|
-
|
|
444
|
-
|
|
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.
|
|
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.",
|