@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 +38 -0
- package/dist/cli/index.js +141 -5
- 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/claudeConfig.js +171 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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). */
|
|
@@ -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" };
|
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.",
|