@davesheffer/hunch 1.10.2 → 1.10.4
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/dist/cli/index.js +2 -2
- package/dist/constitution/repairPolicies.js +20 -6
- package/dist/core/conformance.js +3 -2
- package/dist/core/correction.js +28 -1
- package/dist/core/strictgate.js +5 -2
- package/dist/core/topics.js +15 -0
- package/dist/extractors/git.js +37 -12
- package/dist/integrations/gitignore.js +2 -0
- package/dist/mcp/server.js +16 -10
- package/dist/store/hunchStore.js +44 -19
- package/dist/synthesis/synthesize.js +25 -4
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -68,7 +68,7 @@ import { computeDrift } from "../core/drift.js";
|
|
|
68
68
|
import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
|
|
69
69
|
import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
|
|
70
70
|
import { adoptProsePrompt } from "../wiki/adopt.js";
|
|
71
|
-
import { topicCollisions, renderGrounding } from "../core/topics.js";
|
|
71
|
+
import { topicCollisions, renderGrounding, isInForce } from "../core/topics.js";
|
|
72
72
|
import { pendingEscalations, policyEscalations } from "../core/escalations.js";
|
|
73
73
|
import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
|
|
74
74
|
import { compareCandidates } from "../core/compare.js";
|
|
@@ -3420,7 +3420,7 @@ vetoCmd
|
|
|
3420
3420
|
let drafted = 0;
|
|
3421
3421
|
let touched = 0;
|
|
3422
3422
|
for (const d of store.json.loadAll("decisions")) {
|
|
3423
|
-
if (d
|
|
3423
|
+
if (!isInForce(d))
|
|
3424
3424
|
continue;
|
|
3425
3425
|
if (!d.alternatives_rejected.length)
|
|
3426
3426
|
continue;
|
|
@@ -30,9 +30,16 @@ export function planPolicyRepair(renames, policies) {
|
|
|
30
30
|
rewrites.push({ id: p.id, field: "scope.paths", from: path, to });
|
|
31
31
|
}
|
|
32
32
|
if (p.assertion.kind !== "executable-behavior") {
|
|
33
|
+
// must-pass-through carries a THIRD selector (`via`). Omitting it meant an
|
|
34
|
+
// ordinary rename left the via binding pointing at the old symbol: the policy
|
|
35
|
+
// either bricked (its semantic hash moved, invalidating the proof, while the
|
|
36
|
+
// stale via could never be re-proved) or silently stopped enforcing. This runs
|
|
37
|
+
// automatically from the post-commit hook, so neither outcome was visible.
|
|
33
38
|
const selectors = p.assertion.kind === "exists"
|
|
34
39
|
? [p.assertion.subject.selector]
|
|
35
|
-
:
|
|
40
|
+
: p.assertion.kind === "must-pass-through"
|
|
41
|
+
? [p.assertion.subject.selector, p.assertion.via.selector, p.assertion.object.selector]
|
|
42
|
+
: [p.assertion.subject.selector, p.assertion.object.selector];
|
|
36
43
|
for (const raw of selectors) {
|
|
37
44
|
const healed = repairSelector(raw, map);
|
|
38
45
|
if (healed !== raw)
|
|
@@ -54,11 +61,18 @@ export function repairPolicySpec(policy, rewrites, at) {
|
|
|
54
61
|
? policy.assertion
|
|
55
62
|
: policy.assertion.kind === "exists"
|
|
56
63
|
? { ...policy.assertion, subject: { selector: subSelector(policy.assertion.subject.selector) } }
|
|
57
|
-
:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
: policy.assertion.kind === "must-pass-through"
|
|
65
|
+
? {
|
|
66
|
+
...policy.assertion,
|
|
67
|
+
subject: { selector: subSelector(policy.assertion.subject.selector) },
|
|
68
|
+
via: { selector: subSelector(policy.assertion.via.selector) },
|
|
69
|
+
object: { selector: subSelector(policy.assertion.object.selector) },
|
|
70
|
+
}
|
|
71
|
+
: {
|
|
72
|
+
...policy.assertion,
|
|
73
|
+
subject: { selector: subSelector(policy.assertion.subject.selector) },
|
|
74
|
+
object: { selector: subSelector(policy.assertion.object.selector) },
|
|
75
|
+
};
|
|
62
76
|
return {
|
|
63
77
|
...policy,
|
|
64
78
|
revision: policy.revision + 1,
|
package/dist/core/conformance.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { externalImportNodeId } from "./externalImports.js";
|
|
2
|
+
import { isInForce } from "./topics.js";
|
|
2
3
|
function resolveSymbols(graph, ref) {
|
|
3
4
|
const syms = graph.symbols;
|
|
4
5
|
if (ref.startsWith("sym_"))
|
|
@@ -102,8 +103,8 @@ export function checkConformance(store, opts = {}) {
|
|
|
102
103
|
const graph = opts.graph ?? { symbols: load("symbols"), edges: load("edges") };
|
|
103
104
|
const out = [];
|
|
104
105
|
for (const d of load("decisions")) {
|
|
105
|
-
if (d
|
|
106
|
-
continue; // in-force
|
|
106
|
+
if (!isInForce(d))
|
|
107
|
+
continue; // in-force only — a REJECTED intent is not an intent
|
|
107
108
|
for (const p of d.conformance ?? [])
|
|
108
109
|
out.push(evalPredicate(graph, d, p));
|
|
109
110
|
}
|
package/dist/core/correction.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - buildCorrectionConstraint(): mint the Constraint record (human-confirmed,
|
|
9
9
|
* scoped conservatively) that the pre-edit hook + CI guard then enforce.
|
|
10
10
|
*/
|
|
11
|
+
import { isAbsolute, relative } from "node:path";
|
|
11
12
|
import { constraintId } from "./ids.js";
|
|
12
13
|
import { toPosixTarget } from "./paths.js";
|
|
13
14
|
import { deriveForbids } from "./constraintmatch.js";
|
|
@@ -40,6 +41,32 @@ export const CORRECTION_NUDGE = "This looks like a correction. If it's a rule th
|
|
|
40
41
|
"call hunch_record_correction({ rule, scope_hint_file, severity, applies_to_all }) so it " +
|
|
41
42
|
"becomes an enforced, scoped constraint (held at edit-time and in CI) — not a one-off the next session forgets. " +
|
|
42
43
|
"Use severity:\"blocking\" only when the human said never/must; set applies_to_all:true only if the rule is genuinely repo-wide.";
|
|
44
|
+
/** Normalize a scope hint to a repo-relative POSIX path.
|
|
45
|
+
*
|
|
46
|
+
* An ABSOLUTE hint is the shape an agent naturally sends, but `checkConstraints`
|
|
47
|
+
* anchors its globs at `^` against repo-relative paths, so an absolute scope matches
|
|
48
|
+
* NOTHING — while the constraint keeps `severity: "blocking"` and
|
|
49
|
+
* `provenance: human_confirmed`, and the MCP tool affirmatively reports it as enforced
|
|
50
|
+
* at edit time and in CI. It also leaks the developer's local filesystem path into the
|
|
51
|
+
* committed graph and CLAUDE.md.
|
|
52
|
+
*
|
|
53
|
+
* Returns "" when the hint cannot be made repo-relative (no root, or a path outside the
|
|
54
|
+
* repo). The caller then falls back to "**", where the existing severity guard
|
|
55
|
+
* down-ranks a non-explicit blocking rule to a warning — fail-safe and honest, rather
|
|
56
|
+
* than a blocking rule enforced nowhere. */
|
|
57
|
+
function repoRelativeHint(rawHint, root) {
|
|
58
|
+
if (!rawHint)
|
|
59
|
+
return "";
|
|
60
|
+
const looksAbsolute = isAbsolute(rawHint) || /^[a-zA-Z]:/.test(rawHint);
|
|
61
|
+
if (!looksAbsolute)
|
|
62
|
+
return rawHint;
|
|
63
|
+
if (!root)
|
|
64
|
+
return "";
|
|
65
|
+
const rel = toPosixTarget(relative(root, rawHint));
|
|
66
|
+
if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || /^[a-zA-Z]:/.test(rel))
|
|
67
|
+
return "";
|
|
68
|
+
return rel;
|
|
69
|
+
}
|
|
43
70
|
/**
|
|
44
71
|
* Build the Constraint a correction mints. Pure (caller passes `now`), so the
|
|
45
72
|
* scope/severity policy is testable in isolation. Key safety rule (research
|
|
@@ -55,7 +82,7 @@ export function buildCorrectionConstraint(input, now) {
|
|
|
55
82
|
// A blank/"." scope hint would mint a meaningless or repo-wide constraint by
|
|
56
83
|
// accident, so fall back to "**" (which the severity guard below then keeps
|
|
57
84
|
// non-blocking unless applies_to_all was explicitly set).
|
|
58
|
-
const hinted = input.scope_hint_file ? toPosixTarget(input.scope_hint_file) : "";
|
|
85
|
+
const hinted = repoRelativeHint(input.scope_hint_file ? toPosixTarget(input.scope_hint_file) : "", input.root);
|
|
59
86
|
const scope = input.applies_to_all || !hinted || hinted === "." ? ["**"] : [hinted];
|
|
60
87
|
const repoWide = scope.length === 1 && scope[0] === "**";
|
|
61
88
|
let severity = input.severity ?? "warning";
|
package/dist/core/strictgate.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* weaker signals still print, as advisory. This makes strict mode safe to enable
|
|
9
9
|
* on a shared repo: a false positive downgrades to a warning instead of wrongly
|
|
10
10
|
* failing a teammate's commit. */
|
|
11
|
+
import { isInForce } from "./topics.js";
|
|
11
12
|
export const STRICT_MIN_CONFIDENCE = 0.8;
|
|
12
13
|
/** Is a provenance source HUMAN-CONFIRMED? Token-aware, so a composite source like
|
|
13
14
|
* "llm_draft+human_confirmed" counts, but a lookalike ("not_human_confirmed",
|
|
@@ -34,8 +35,10 @@ export function isStrictBlocker(c, stale) {
|
|
|
34
35
|
* warns. Semantic similarity never blocks. In-force + non-stale gates run first.
|
|
35
36
|
* This is what makes the "day-one is advisory" DX true (dec_a466655539). */
|
|
36
37
|
export function isVetoBlocker(d, tw, tier, stale) {
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// Shared in-force predicate (core/topics.ts): superseded, REJECTED and
|
|
39
|
+
// window-closed decisions all lose their teeth here, not just superseded ones.
|
|
40
|
+
if (!isInForce(d))
|
|
41
|
+
return false;
|
|
39
42
|
if (stale)
|
|
40
43
|
return false; // freshness gate
|
|
41
44
|
if (tier === "semantic")
|
package/dist/core/topics.js
CHANGED
|
@@ -4,6 +4,21 @@
|
|
|
4
4
|
export function isLive(d) {
|
|
5
5
|
return d.status === "accepted" && d.superseded_by === null && d.valid_to === null;
|
|
6
6
|
}
|
|
7
|
+
/** In force for ENFORCEMENT — the one predicate every guard must share.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately BROADER than `isLive`: a `proposed` draft still contributes ADVISORY
|
|
10
|
+
* signal (its tripwires are `llm_draft`, which `isVetoBlocker` can never promote to a
|
|
11
|
+
* block), and that is the curate loop working as designed. What must never contribute
|
|
12
|
+
* is a decision the team formally REJECTED, or one whose valid-time window was closed.
|
|
13
|
+
*
|
|
14
|
+
* The guards previously each inlined a weaker `superseded`-only copy of this test, so a
|
|
15
|
+
* REJECTED decision kept driving the veto, regression, retired-symbol and conformance
|
|
16
|
+
* guards — it went on blocking commits and injecting "don't re-add this" into the
|
|
17
|
+
* pre-edit hook, with no way to un-stick it short of hand-editing the JSON. Structural
|
|
18
|
+
* parameter so the strict gate (which sees only a partial record) shares it too. */
|
|
19
|
+
export function isInForce(d) {
|
|
20
|
+
return d.status !== "superseded" && d.status !== "rejected" && !d.superseded_by && !d.valid_to;
|
|
21
|
+
}
|
|
7
22
|
/** Every live decision anchored to `topic`. In a healthy graph this is length 0 or 1;
|
|
8
23
|
* length > 1 is a topic collision the §4 resolution must settle. */
|
|
9
24
|
export function liveForTopic(decisions, topic) {
|
package/dist/extractors/git.js
CHANGED
|
@@ -590,8 +590,8 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
590
590
|
// a clean overlay store — most dangerously, the overlay was never its own git repo so `git -C`
|
|
591
591
|
// walked UP to the PROJECT repo. Committing/pushing there would overwrite/delete the user's
|
|
592
592
|
// code (we shipped exactly this). Refuse hard: unstage and bail without committing or pushing.
|
|
593
|
-
const
|
|
594
|
-
if (
|
|
593
|
+
const staged = stagedMemoryPaths(hunchDir, env);
|
|
594
|
+
if (staged === null) {
|
|
595
595
|
try {
|
|
596
596
|
execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
|
|
597
597
|
}
|
|
@@ -605,6 +605,16 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
605
605
|
}
|
|
606
606
|
return null;
|
|
607
607
|
}
|
|
608
|
+
// Unstage every derived artifact the scan skipped (catch-log, SQLite index,
|
|
609
|
+
// atomic-write temps). A public store keeps ordinary ignore semantics, so one
|
|
610
|
+
// created before those ignore entries existed still stages them above — and the
|
|
611
|
+
// commit below is `--only` over the memory paths, which would leave them in the
|
|
612
|
+
// index forever. A permanently dirty index is what the release gate reads as an
|
|
613
|
+
// unstable tree, and it is exactly what `git reset -- .` used to clean up as a
|
|
614
|
+
// side effect of the abort path these artifacts no longer take.
|
|
615
|
+
if (staged.derived.length)
|
|
616
|
+
run(["reset", "-q", "--", ...staged.derived]);
|
|
617
|
+
const memoryPaths = staged.memory;
|
|
608
618
|
if (memoryPaths.length === 0)
|
|
609
619
|
return null;
|
|
610
620
|
// Grounding docs refreshed by this capture ride the same memory commit, so committed record
|
|
@@ -727,11 +737,6 @@ export function headFileContent(root, rel) {
|
|
|
727
737
|
return null;
|
|
728
738
|
}
|
|
729
739
|
}
|
|
730
|
-
/** Is the staged set a clean, MEMORY-ONLY change — only JSON record adds/updates, nothing else?
|
|
731
|
-
* The overlay store is entirely JSON (decisions/, bugs/, …, manifest.json). A real memory sync
|
|
732
|
-
* is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
|
|
733
|
-
* clean overlay repo (e.g. it resolved to the project repo), so committing there would clobber
|
|
734
|
-
* code. Empty stage ⇒ [] (nothing to commit); invalid stage ⇒ null. The transient mkdir lock is ignored. */
|
|
735
740
|
function stagedMemoryPaths(hunchDir, env) {
|
|
736
741
|
let out = "";
|
|
737
742
|
let prefix = "";
|
|
@@ -751,8 +756,9 @@ function stagedMemoryPaths(hunchDir, env) {
|
|
|
751
756
|
}
|
|
752
757
|
const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
753
758
|
if (!lines.length)
|
|
754
|
-
return [];
|
|
759
|
+
return { memory: [], derived: [] };
|
|
755
760
|
const memoryPaths = [];
|
|
761
|
+
const derivedPaths = [];
|
|
756
762
|
for (const line of lines) {
|
|
757
763
|
const parts = line.split("\t");
|
|
758
764
|
const status = (parts[0] ?? "").trim();
|
|
@@ -767,11 +773,32 @@ function stagedMemoryPaths(hunchDir, env) {
|
|
|
767
773
|
const memoryRelativePath = normalizedPath.slice(prefix.length);
|
|
768
774
|
if (!memoryRelativePath || memoryRelativePath === "local.json")
|
|
769
775
|
return null; // machine-local overlay pointer; never publish it
|
|
776
|
+
// Known CLONE-LOCAL/DERIVED artifacts are skipped, never treated as a topology
|
|
777
|
+
// violation — mirroring committableOverlayJsonPaths below, which already carves
|
|
778
|
+
// out exactly these. Without this, the strict hook's own catch-log
|
|
779
|
+
// (.hunch/events.log, append-only with no rotation) made this function return
|
|
780
|
+
// null forever from its first line onward: the public flush then unstaged
|
|
781
|
+
// everything and aborted SILENTLY on every later capture, while the MCP tool
|
|
782
|
+
// still reported success and records piled up untracked.
|
|
783
|
+
if (isDerivedStoreArtifact(memoryRelativePath)) {
|
|
784
|
+
derivedPaths.push(memoryRelativePath);
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
770
787
|
if (!normalizedPath.endsWith(".json"))
|
|
771
788
|
return null; // the store is entirely JSON records
|
|
772
789
|
memoryPaths.push(memoryRelativePath);
|
|
773
790
|
}
|
|
774
|
-
return [...new Set(memoryPaths)];
|
|
791
|
+
return { memory: [...new Set(memoryPaths)], derived: [...new Set(derivedPaths)] };
|
|
792
|
+
}
|
|
793
|
+
/** Clone-local / DERIVED artifacts that legitimately sit inside a `.hunch` store but
|
|
794
|
+
* are never memory records: the SQLite index, temp files, and the strict hook's
|
|
795
|
+
* append-only catch-log. A store carrying one of these is a NORMAL store, not a
|
|
796
|
+
* topology violation — so both enumerators skip them rather than refusing the whole
|
|
797
|
+
* commit. Shared so the public and overlay paths can never disagree again. */
|
|
798
|
+
function isDerivedStoreArtifact(relativeName) {
|
|
799
|
+
return /^[^/]+\.sqlite[^/]*$/i.test(relativeName)
|
|
800
|
+
|| relativeName.split("/").some((segment) => segment.includes(".tmp"))
|
|
801
|
+
|| relativeName === "events.log";
|
|
775
802
|
}
|
|
776
803
|
/** Enumerate ordinary JSON files already contained under an overlay. Push-capable
|
|
777
804
|
* stores force-add this exact allowlist so remote .gitignore, info/exclude, or an
|
|
@@ -806,9 +833,7 @@ function committableOverlayJsonPaths(hunchDir) {
|
|
|
806
833
|
else if (relativeName === "local.json") {
|
|
807
834
|
return false;
|
|
808
835
|
}
|
|
809
|
-
else if (
|
|
810
|
-
|| relativeName.split("/").some((segment) => segment.includes(".tmp"))
|
|
811
|
-
|| relativeName === "events.log") {
|
|
836
|
+
else if (isDerivedStoreArtifact(relativeName)) {
|
|
812
837
|
// Known clone-local/derived artifacts are never staged. Everything
|
|
813
838
|
// else is a topology violation: a shared graph repository cannot
|
|
814
839
|
// quietly carry arbitrary source alongside its JSON memory.
|
|
@@ -24,6 +24,8 @@ const ENTRIES = [
|
|
|
24
24
|
// Per-machine private-overlay pointer written by `hunch private` (holds the local
|
|
25
25
|
// path to the private store) — never committed.
|
|
26
26
|
".hunch/local.json",
|
|
27
|
+
// The strict hook's append-only catch-log: clone-local, never a memory record.
|
|
28
|
+
".hunch/events.log",
|
|
27
29
|
// A local PRIVATE overlay store (HUNCH_PRIVATE_DIR) for sensitive memory — never
|
|
28
30
|
// committed. This is the conventional in-repo path; point the env elsewhere for a
|
|
29
31
|
// fully separate private repo.
|
package/dist/mcp/server.js
CHANGED
|
@@ -821,10 +821,14 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
821
821
|
// Route the write to its ONE home: an explicit private:true goes to the overlay
|
|
822
822
|
// (putPrivate throws rather than silently falling public); in unified ("shared")
|
|
823
823
|
// mode EVERY capture goes to the overlay; else the public store.
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
824
|
+
// Through putCapture, NOT the raw per-home writers: it carries the cross-home
|
|
825
|
+
// twin guard. Branching on `home` here bypassed that guard, so one id could
|
|
826
|
+
// exist in BOTH stores — after which the merged/private-first read makes the
|
|
827
|
+
// topic-uniqueness check see one record while a later public `supersedes:`
|
|
828
|
+
// closes the other, leaving two live decisions on one topic and grounding
|
|
829
|
+
// silently injecting nothing for it. The guard throws; the surrounding catch
|
|
830
|
+
// turns that into a clean tool error instead of a silent twin.
|
|
831
|
+
store.putCapture("decisions", rec, !!decision.private);
|
|
828
832
|
// Invalidate, don't delete: closing the superseded decision's valid-time window
|
|
829
833
|
// (+ a supersedes edge) preserves the why-it-changed trail. Route the close to the
|
|
830
834
|
// same store the new record landed in — a private decision supersedes within the
|
|
@@ -869,7 +873,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
869
873
|
description: "When a human corrects the agent ('no, do it this way' / 'never call X here'), persist that correction as a first-class, SCOPED Constraint with provenance — so the pre-edit hook and the CI Constraint Guard hold EVERY assistant to it from now on, instead of it being forgotten next session. Writes to the shared .hunch/ graph (client-agnostic). Set severity:'blocking' only when the human said never/must; set applies_to_all:true only when the rule is genuinely repo-wide (otherwise it is scoped to scope_hint_file).",
|
|
870
874
|
inputSchema: {
|
|
871
875
|
rule: z.string().describe("The invariant in the human's words, e.g. \"never call the pay-per-token API here\"."),
|
|
872
|
-
scope_hint_file: z.string().optional().describe("A file the correction was about; scopes the constraint to it (the conservative default)."),
|
|
876
|
+
scope_hint_file: z.string().optional().describe("A file the correction was about; scopes the constraint to it (the conservative default). Prefer a REPO-RELATIVE path (src/foo.ts); an absolute path is relativized against the repo root, and one outside the repo is discarded rather than scoped to a path that could never match."),
|
|
873
877
|
severity: z.enum(["advisory", "warning", "blocking"]).optional().describe("Default 'warning'. Use 'blocking' only for a hard never/must rule."),
|
|
874
878
|
applies_to_all: z.boolean().optional().describe("True ONLY if the rule is genuinely repo-wide (scopes to **); required to make a repo-wide rule blocking."),
|
|
875
879
|
type: z.enum(["security", "performance", "correctness", "architecture", "compliance"]).optional(),
|
|
@@ -881,7 +885,11 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
881
885
|
try {
|
|
882
886
|
if (!input.rule || !input.rule.trim())
|
|
883
887
|
return err("rule is required — state the invariant in plain words.");
|
|
884
|
-
|
|
888
|
+
// root: relativizes an ABSOLUTE scope_hint_file. Agents naturally send absolute
|
|
889
|
+
// paths (edit-tool payloads and MCP roots are absolute) and every consumer matches
|
|
890
|
+
// repo-relative — without this the rule would be blocking-but-inert and would leak
|
|
891
|
+
// the local filesystem path into the committed graph.
|
|
892
|
+
const rec = buildCorrectionConstraint({ ...input, knownDeps: knownRepoDeps(root), root }, new Date().toISOString());
|
|
885
893
|
// Private corrections go to the overlay (enforced locally via the merged read,
|
|
886
894
|
// never rendered into the public CI comment, which is public-only by construction).
|
|
887
895
|
const home = store.captureHome(!!input.private);
|
|
@@ -890,10 +898,8 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
890
898
|
return err(`Refusing to record public correction ${rec.id}: source decision ${rec.source_decision} ${location}.`);
|
|
891
899
|
}
|
|
892
900
|
const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
else
|
|
896
|
-
store.json.put("constraints", rec);
|
|
901
|
+
// Same cross-home twin guard as the decision path above.
|
|
902
|
+
store.putCapture("constraints", rec, !!input.private);
|
|
897
903
|
store.reindex();
|
|
898
904
|
// Propagate the new rule to EVERY assistant's ambient grounding (Cursor/Copilot/
|
|
899
905
|
// Windsurf/AGENTS.md/CLAUDE.md), so a correction captured in one assistant is held
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -20,7 +20,7 @@ import { selectEmbedder } from "./embedder.js";
|
|
|
20
20
|
import { JsonStore } from "./jsonStore.js";
|
|
21
21
|
import { gitCommonDir, gitWorktreeRoot, sameGitPublication } from "../extractors/git.js";
|
|
22
22
|
import { pathMatchesGlob } from "../core/glob.js";
|
|
23
|
-
import { currentForTopic } from "../core/topics.js";
|
|
23
|
+
import { currentForTopic, isInForce } from "../core/topics.js";
|
|
24
24
|
import { edgeId } from "../core/ids.js";
|
|
25
25
|
import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
|
|
26
26
|
import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
|
|
@@ -481,14 +481,30 @@ export class HunchStore {
|
|
|
481
481
|
return { embedded, skipped: docs.length - todo.length, total: docs.length };
|
|
482
482
|
}
|
|
483
483
|
/** Post-fusion rerank by graph PRIORS (dec_25e277f479): relevance ordering, not
|
|
484
|
-
* just reachability.
|
|
485
|
-
* provenance × recency — so bm25's negative scale never leaks in. Priors are
|
|
486
|
-
* BOUNDED (a superseded or ancient record dims, never disappears): liveness 0.6
|
|
484
|
+
* just reachability. Trust weight w = liveness × provenance × recency: liveness 0.6
|
|
487
485
|
* for superseded/retired/rejected, provenance 1.0 / 0.85 / 0.75 for
|
|
488
486
|
* human_confirmed / llm_draft / extracted-inferred, recency 0.7 + 0.3·½^(age/90d).
|
|
489
487
|
* Runbook trigger phrases matching the query boost ×1.5 (exact intent beats
|
|
490
488
|
* keyword luck). Structural refs (symbols/components/edges) stay neutral.
|
|
491
|
-
*
|
|
489
|
+
*
|
|
490
|
+
* The prior is applied as a BOUNDED POSITIONAL SHIFT, not as a multiplier on a
|
|
491
|
+
* rank-derived score. That is deliberate and load-bearing. The old form —
|
|
492
|
+
* `1/(60+i) × w`, sorted descending, sliced to `limit` — could not keep the
|
|
493
|
+
* "dims, never disappears" promise it made: across a fused pool of 24 the
|
|
494
|
+
* positional term spans only 1/60…1/83 (a 0.72 ratio) while w spans 0.48…1.0 on
|
|
495
|
+
* this repo's own records, so the prior was WIDER than the entire pool's
|
|
496
|
+
* positional spread and simply overrode fusion. Worse, `priorMeta` returns null
|
|
497
|
+
* for structural refs, which left them at w = 1 — a ceiling above what any record
|
|
498
|
+
* that is not both human_confirmed and brand-new can reach. Measured over this
|
|
499
|
+
* repo's 152 committed decisions with the rest of the pool structural (symbols are
|
|
500
|
+
* ~91% of the corpus): 38% of decisions were dropped from the top-12 even when
|
|
501
|
+
* they were the #1 fused hit, and 88% from fused rank 8. Both retrieval layers
|
|
502
|
+
* ranked the record first and the rerank alone threw it away.
|
|
503
|
+
*
|
|
504
|
+
* A shift of at most ±MAX_PRIOR_SHIFT positions restores the intended semantics:
|
|
505
|
+
* a stale or low-provenance record visibly dims, an exact runbook-trigger match
|
|
506
|
+
* visibly promotes, and neither can leapfrog the whole pool. Same measurement
|
|
507
|
+
* after: 0% evicted from fused ranks 0–8. Deterministic; ties keep fused order. */
|
|
492
508
|
rerankByPriors(hits, limit, query) {
|
|
493
509
|
if (!hits.length)
|
|
494
510
|
return hits; // a SINGLE hit still runs — topic-chain promotion must fire for the lone stale match
|
|
@@ -499,10 +515,10 @@ export class HunchStore {
|
|
|
499
515
|
// predecessor's rank — the reader asked about the topic, and the graph's one
|
|
500
516
|
// live answer must be reachable even when only history matches lexically.
|
|
501
517
|
const present = new Set(hits.map((h) => h.ref));
|
|
502
|
-
// Each candidate carries its
|
|
503
|
-
//
|
|
504
|
-
// live record already in the pool is never displaced by an injection
|
|
505
|
-
const pool = hits.map((h, i) => ({ h,
|
|
518
|
+
// Each candidate carries its fused POSITION; an injected successor inherits its
|
|
519
|
+
// predecessor's position + a half step, so it lands immediately after it and an
|
|
520
|
+
// exact-match live record already in the pool is never displaced by an injection.
|
|
521
|
+
const pool = hits.map((h, i) => ({ h, pos: i }));
|
|
506
522
|
for (const [i, h] of hits.entries()) {
|
|
507
523
|
if (h.kind !== "decisions")
|
|
508
524
|
continue;
|
|
@@ -515,10 +531,10 @@ export class HunchStore {
|
|
|
515
531
|
present.add(cur.id);
|
|
516
532
|
pool.push({
|
|
517
533
|
h: { ref: cur.id, kind: "decisions", title: cur.title, snippet: `current for topic "${d.topic}" (supersedes ${h.ref})`, score: h.score },
|
|
518
|
-
|
|
534
|
+
pos: i + 0.5,
|
|
519
535
|
});
|
|
520
536
|
}
|
|
521
|
-
const scored = pool.map(({ h,
|
|
537
|
+
const scored = pool.map(({ h, pos }) => {
|
|
522
538
|
const m = this.priorMeta(h.ref, h.kind);
|
|
523
539
|
let w = 1;
|
|
524
540
|
if (m) {
|
|
@@ -533,9 +549,11 @@ export class HunchStore {
|
|
|
533
549
|
if (q && m.triggers?.some((tr) => q.includes(tr) || tr.includes(q)))
|
|
534
550
|
w *= 1.5;
|
|
535
551
|
}
|
|
536
|
-
|
|
552
|
+
// w > 1 (a trigger match) shifts UP, w < 1 shifts DOWN, both clamped.
|
|
553
|
+
const shift = Math.max(-MAX_PRIOR_SHIFT, Math.min(MAX_PRIOR_SHIFT, (1 - w) * PRIOR_SHIFT_SCALE));
|
|
554
|
+
return { h, pos: pos + shift };
|
|
537
555
|
});
|
|
538
|
-
scored.sort((a, b) =>
|
|
556
|
+
scored.sort((a, b) => a.pos - b.pos);
|
|
539
557
|
return scored.slice(0, limit).map((x) => x.h);
|
|
540
558
|
}
|
|
541
559
|
/** The prior-bearing metadata for a hit: liveness, provenance, effective date,
|
|
@@ -1254,9 +1272,9 @@ export class HunchStore {
|
|
|
1254
1272
|
// attribution (the strict guard fails on `blocking`).
|
|
1255
1273
|
const ordered = [...decisions].sort((a, b) => Number(blockingDec.has(b.id)) - Number(blockingDec.has(a.id)));
|
|
1256
1274
|
for (const d of ordered) {
|
|
1257
|
-
// Only IN-FORCE decisions: re-adding what an OUTDATED (superseded)
|
|
1258
|
-
// removed is not a regression against the current design.
|
|
1259
|
-
if (d
|
|
1275
|
+
// Only IN-FORCE decisions: re-adding what an OUTDATED (superseded), REJECTED, or
|
|
1276
|
+
// window-closed decision removed is not a regression against the current design.
|
|
1277
|
+
if (!isInForce(d))
|
|
1260
1278
|
continue;
|
|
1261
1279
|
if (!d.retired.symbols.length && !d.retired.deps.length)
|
|
1262
1280
|
continue;
|
|
@@ -1283,8 +1301,8 @@ export class HunchStore {
|
|
|
1283
1301
|
const out = [];
|
|
1284
1302
|
const seen = new Set(); // dedup: one hit per decision+alternative
|
|
1285
1303
|
for (const d of this.recs("decisions")) {
|
|
1286
|
-
if (d
|
|
1287
|
-
continue; // in-force only
|
|
1304
|
+
if (!isInForce(d))
|
|
1305
|
+
continue; // in-force only (excludes rejected + window-closed)
|
|
1288
1306
|
const tripwires = d.rejected_tripwires ?? [];
|
|
1289
1307
|
if (!tripwires.length)
|
|
1290
1308
|
continue;
|
|
@@ -1353,7 +1371,7 @@ export class HunchStore {
|
|
|
1353
1371
|
retiredForFile(file) {
|
|
1354
1372
|
const out = [];
|
|
1355
1373
|
for (const d of this.recs("decisions")) {
|
|
1356
|
-
if (d
|
|
1374
|
+
if (!isInForce(d))
|
|
1357
1375
|
continue;
|
|
1358
1376
|
if (!d.retired.symbols.length && !d.retired.deps.length)
|
|
1359
1377
|
continue;
|
|
@@ -1526,6 +1544,13 @@ const RRF_W_FTS = numEnv("HUNCH_RRF_W_FTS", 1);
|
|
|
1526
1544
|
const RRF_W_SEM = numEnv("HUNCH_RRF_W_SEM", 0.7);
|
|
1527
1545
|
const RRF_W_GRAPH = numEnv("HUNCH_RRF_W_GRAPH", 0.5);
|
|
1528
1546
|
const GRAPH_GAMMA = numEnv("HUNCH_GRAPH_GAMMA", 0.25);
|
|
1547
|
+
/** Prior tuning: how far a trust weight may move a hit from its FUSED position.
|
|
1548
|
+
* SCALE maps the weight's realistic span onto positions (this repo's own decisions
|
|
1549
|
+
* span w 0.48…1.0, so ×12 reaches the clamp at the low end); MAX_PRIOR_SHIFT is the
|
|
1550
|
+
* hard bound that keeps the prior a dimmer rather than the primary sort key — see
|
|
1551
|
+
* rerankByPriors for the measurement that fixed it at 4. */
|
|
1552
|
+
const PRIOR_SHIFT_SCALE = numEnv("HUNCH_PRIOR_SHIFT_SCALE", 12);
|
|
1553
|
+
const MAX_PRIOR_SHIFT = numEnv("HUNCH_MAX_PRIOR_SHIFT", 4);
|
|
1529
1554
|
function numEnv(name, dflt) {
|
|
1530
1555
|
const v = Number(process.env[name]);
|
|
1531
1556
|
// >= 0, not > 0: zero is the documented kill-switch (HUNCH_RRF_W_*=0 disables
|
|
@@ -271,7 +271,7 @@ export async function recordFailure(store, root, failure, opts = {}) {
|
|
|
271
271
|
// a regression Constraint to stop it coming back, and bumps fragility.
|
|
272
272
|
let constraint;
|
|
273
273
|
if (shouldPromoteConstraint(draft.severity, bug.root_cause, !!prior)) {
|
|
274
|
-
constraint = promoteConstraint(store, bug, home);
|
|
274
|
+
constraint = promoteConstraint(store, bug, home, !!prior);
|
|
275
275
|
bug.lineage.spawned_constraint = constraint.id;
|
|
276
276
|
putBugInHome(store, bug, home);
|
|
277
277
|
}
|
|
@@ -330,16 +330,37 @@ export function shouldPromoteConstraint(severity, rootCause, isRecurrence) {
|
|
|
330
330
|
const severe = severity === "high" || severity === "critical";
|
|
331
331
|
return severe && rootCause.trim().length > 0;
|
|
332
332
|
}
|
|
333
|
-
/** Turn a bug into
|
|
334
|
-
|
|
333
|
+
/** Turn a bug into a regression constraint scoped to its files.
|
|
334
|
+
*
|
|
335
|
+
* Severity policy mirrors buildCorrectionConstraint's scope-footgun guard, for the
|
|
336
|
+
* same reason: a BLOCKING constraint DENIES edits under strict firmness, and neither
|
|
337
|
+
* input here is human-confirmed. `bug.severity` is whatever the synthesis provider's
|
|
338
|
+
* enum emitted (provenance "derived", an LLM label no human saw), and `affected_files`
|
|
339
|
+
* is EMPTY whenever suspect ranking resolves nothing — the common case for a failure
|
|
340
|
+
* that names no known symbol. Left unguarded those compose into the worst outcome:
|
|
341
|
+
* one model-labeled "critical" failure with no resolvable suspects mints
|
|
342
|
+
* scope ["**"] + severity "blocking", a repo-wide deny on every subsequent edit.
|
|
343
|
+
*
|
|
344
|
+
* So blocking requires BOTH a real file scope AND deterministic corroboration — a
|
|
345
|
+
* recurrence, meaning the graph itself already saw and closed this symptom. Everything
|
|
346
|
+
* else lands as a warning: still surfaced at edit time and in CI, and still promotable
|
|
347
|
+
* by a human via `hunch record-constraint`, but never an automatic deny.
|
|
348
|
+
*
|
|
349
|
+
* Exported for the same reason as shouldPromoteConstraint — it is policy, and policy
|
|
350
|
+
* should be provable without spinning up a provider. */
|
|
351
|
+
export function shouldBlockOnPromotion(severity, affectedFiles, isRecurrence) {
|
|
352
|
+
return severity === "critical" && affectedFiles.length > 0 && isRecurrence;
|
|
353
|
+
}
|
|
354
|
+
function promoteConstraint(store, bug, home, isRecurrence) {
|
|
335
355
|
const scope = bug.affected_files.length ? bug.affected_files : ["**"];
|
|
356
|
+
const blocking = shouldBlockOnPromotion(bug.severity, bug.affected_files, isRecurrence);
|
|
336
357
|
const statement = `Regression guard: "${bug.title}" must not recur.`;
|
|
337
358
|
const con = {
|
|
338
359
|
id: constraintId(statement),
|
|
339
360
|
type: bug.severity === "critical" ? "security" : "correctness",
|
|
340
361
|
statement,
|
|
341
362
|
scope,
|
|
342
|
-
severity:
|
|
363
|
+
severity: blocking ? "blocking" : "warning",
|
|
343
364
|
enforcement: "advisory_v1",
|
|
344
365
|
match: null,
|
|
345
366
|
forbids: null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.4",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",
|