@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 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.superseded_by || d.status === "superseded")
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
- : [p.assertion.subject.selector, p.assertion.object.selector];
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
- ...policy.assertion,
59
- subject: { selector: subSelector(policy.assertion.subject.selector) },
60
- object: { selector: subSelector(policy.assertion.object.selector) },
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,
@@ -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.status === "superseded" || d.superseded_by)
106
- continue; // in-force decisions only
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
  }
@@ -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";
@@ -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
- if (d.status === "superseded" || d.superseded_by)
38
- return false; // in-force decisions only
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")
@@ -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) {
@@ -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 memoryPaths = stagedMemoryPaths(hunchDir, env);
594
- if (memoryPaths === null) {
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 (/^[^/]+\.sqlite[^/]*$/i.test(relativeName)
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.
@@ -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
- if (home === "private")
825
- store.putPrivate("decisions", rec);
826
- else
827
- store.json.put("decisions", rec);
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
- const rec = buildCorrectionConstraint({ ...input, knownDeps: knownRepoDeps(root) }, new Date().toISOString());
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
- if (home === "private")
894
- store.putPrivate("constraints", rec);
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
@@ -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. Rank-based blend blended(i) = 1/(60+i) × liveness ×
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
- * Deterministic; ties keep fused order (stable sort). */
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 BASE rank position; an injected successor
503
- // inherits its predecessor's position a hair under, so an exact-match
504
- // live record already in the pool is never displaced by an injection).
505
- const pool = hits.map((h, i) => ({ h, base: 1 / (60 + i) }));
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
- base: (1 / (60 + i)) * 0.98,
534
+ pos: i + 0.5,
519
535
  });
520
536
  }
521
- const scored = pool.map(({ h, base }) => {
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
- return { h, s: base * w };
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) => b.s - a.s);
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) decision
1258
- // removed is not a regression against the current design.
1259
- if (d.superseded_by || d.status === "superseded")
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.superseded_by || d.status === "superseded")
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.superseded_by || d.status === "superseded")
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 an advisory regression constraint scoped to its files. */
334
- function promoteConstraint(store, bug, home) {
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: bug.severity === "critical" ? "blocking" : "warning",
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.2",
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.",