@davesheffer/hunch 1.10.3 → 1.10.5
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/core/correction.js +28 -1
- package/dist/extractors/git.js +37 -12
- package/dist/integrations/gitignore.js +2 -0
- package/dist/mcp/server.js +6 -2
- package/dist/store/db.js +43 -5
- package/dist/store/hunchStore.js +37 -12
- package/dist/store/merge.js +40 -0
- package/dist/synthesis/synthesize.js +25 -4
- package/package.json +1 -1
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/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
|
@@ -873,7 +873,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
873
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).",
|
|
874
874
|
inputSchema: {
|
|
875
875
|
rule: z.string().describe("The invariant in the human's words, e.g. \"never call the pay-per-token API here\"."),
|
|
876
|
-
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."),
|
|
877
877
|
severity: z.enum(["advisory", "warning", "blocking"]).optional().describe("Default 'warning'. Use 'blocking' only for a hard never/must rule."),
|
|
878
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."),
|
|
879
879
|
type: z.enum(["security", "performance", "correctness", "architecture", "compliance"]).optional(),
|
|
@@ -885,7 +885,11 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
885
885
|
try {
|
|
886
886
|
if (!input.rule || !input.rule.trim())
|
|
887
887
|
return err("rule is required — state the invariant in plain words.");
|
|
888
|
-
|
|
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());
|
|
889
893
|
// Private corrections go to the overlay (enforced locally via the merged read,
|
|
890
894
|
// never rendered into the public CI comment, which is public-only by construction).
|
|
891
895
|
const home = store.captureHome(!!input.private);
|
package/dist/store/db.js
CHANGED
|
@@ -59,22 +59,60 @@ function createDb(sqlitePath) {
|
|
|
59
59
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
60
60
|
return db;
|
|
61
61
|
}
|
|
62
|
+
/** Does this error mean the derived index FILE itself is unusable?
|
|
63
|
+
*
|
|
64
|
+
* Matched narrowly, on SQLite's own corruption signatures only. An environment failure —
|
|
65
|
+
* a permission denial, a full disk, a locked file — must still propagate: deleting the
|
|
66
|
+
* file would not fix it and would destroy a cache the user may still be able to keep.
|
|
67
|
+
* `SQLITE_CANTOPEN` ("unable to open database file") is deliberately NOT here for that
|
|
68
|
+
* reason: it usually means a permissions or path problem, not corruption. */
|
|
69
|
+
function isCorruptIndexFile(error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
return /database disk image is malformed|file is not a database|file is encrypted or is not a database|malformed database schema|database corruption/i.test(message);
|
|
72
|
+
}
|
|
73
|
+
function discardDerivedIndex(sqlitePath) {
|
|
74
|
+
for (const path of [sqlitePath, `${sqlitePath}-wal`, `${sqlitePath}-shm`])
|
|
75
|
+
rmSync(path, { force: true });
|
|
76
|
+
}
|
|
62
77
|
export function openDb(sqlitePath) {
|
|
63
78
|
mkdirSync(dirname(sqlitePath), { recursive: true });
|
|
64
|
-
|
|
79
|
+
// A corrupt file can fail at OPEN as well as at schema init (node:sqlite opens lazily,
|
|
80
|
+
// so "file is not a database" typically surfaces on the first statement — but not always).
|
|
81
|
+
let db;
|
|
82
|
+
try {
|
|
83
|
+
db = createDb(sqlitePath);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (!isCorruptIndexFile(error))
|
|
87
|
+
throw error;
|
|
88
|
+
discardDerivedIndex(sqlitePath);
|
|
89
|
+
db = createDb(sqlitePath);
|
|
90
|
+
}
|
|
65
91
|
try {
|
|
66
92
|
initializeSchema(db);
|
|
67
93
|
return db;
|
|
68
94
|
}
|
|
69
95
|
catch (error) {
|
|
70
|
-
|
|
96
|
+
// The ENTIRE database is derived from the Git-native JSON in .hunch/ — it is a cache,
|
|
97
|
+
// and a cache that cannot be read should be rebuilt, not fatal. That reasoning was
|
|
98
|
+
// already written here, but it was wired to exactly ONE trigger: RebuildDerivedIndex,
|
|
99
|
+
// thrown only when an fts5 index meets a runtime without the FTS5 module. Every other
|
|
100
|
+
// error propagated as a raw SQLite string, so a corrupt file took out `hunch index`,
|
|
101
|
+
// `query`, `check` and `doctor` at once — and, because the pre-edit hook must emit
|
|
102
|
+
// nothing and exit 0 on any failure (con_03a0b94b2e), it also went SILENTLY blind,
|
|
103
|
+
// permanently, with no command left that could repair it.
|
|
104
|
+
if (!(error instanceof RebuildDerivedIndex) && !isCorruptIndexFile(error)) {
|
|
71
105
|
db.close();
|
|
72
106
|
throw error;
|
|
73
107
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
108
|
+
try {
|
|
109
|
+
db.close();
|
|
110
|
+
}
|
|
111
|
+
catch { /* a corrupt handle may refuse to close; the file goes anyway */ }
|
|
112
|
+
discardDerivedIndex(sqlitePath);
|
|
77
113
|
db = createDb(sqlitePath);
|
|
114
|
+
// Deliberately NOT wrapped in another rebuild attempt: if a freshly created file also
|
|
115
|
+
// fails, the problem is the environment, not the cache, and it must surface.
|
|
78
116
|
initializeSchema(db);
|
|
79
117
|
return db;
|
|
80
118
|
}
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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,
|
|
@@ -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
|
package/dist/store/merge.js
CHANGED
|
@@ -95,9 +95,49 @@ export function pickWinner(ours, theirs) {
|
|
|
95
95
|
const tr = recency(theirs);
|
|
96
96
|
if (orr !== tr)
|
|
97
97
|
return orr > tr ? ours : theirs;
|
|
98
|
+
// A merge must never silently UNDO recorded progress. Closing a bug does not touch
|
|
99
|
+
// `provenance` or `date` — captureTestRun writes
|
|
100
|
+
// `{ ...bug, status: "fixed", lineage: { ...lineage, fixed_commit: sha } }` — so
|
|
101
|
+
// recency() TIES against the still-open side and control always reached the
|
|
102
|
+
// lexicographic tiebreak below. That tiebreak then picked the LESS-resolved record,
|
|
103
|
+
// twice over: `"fixed_commit":null` sorts above `"fixed_commit":"<sha>"` (n > "), and
|
|
104
|
+
// `"status":"open"` sorts above `"status":"fixed"` (o > f). So a clean merge reverted
|
|
105
|
+
// the closure every time, in the subsystem whose entire job is not losing state.
|
|
106
|
+
//
|
|
107
|
+
// Prefer the side carrying more one-way lifecycle evidence. Deterministic and
|
|
108
|
+
// side-independent (a pure function of each record), so A-merges-B and B-merges-A
|
|
109
|
+
// still agree.
|
|
110
|
+
const oe = closureEvidence(ours);
|
|
111
|
+
const te = closureEvidence(theirs);
|
|
112
|
+
if (oe !== te)
|
|
113
|
+
return oe > te ? ours : theirs;
|
|
98
114
|
// Deterministic, side-independent tiebreak so A-merges-B and B-merges-A agree.
|
|
99
115
|
return canon(ours) >= canon(theirs) ? ours : theirs;
|
|
100
116
|
}
|
|
117
|
+
/** Count the one-way lifecycle facts a record carries: a fix commit, a spawned
|
|
118
|
+
* decision/constraint, a supersession, an end of validity. Each is something that
|
|
119
|
+
* HAPPENED and was recorded — never something a merge should quietly discard.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately counts EVIDENCE fields rather than reading `status`: a status string can
|
|
122
|
+
* be moved in either direction (a reopened bug goes fixed -> open), but a recorded
|
|
123
|
+
* `fixed_commit` is a fact about history. Ranking on evidence means a genuine reopen —
|
|
124
|
+
* which clears the commit — is still allowed to win, while a merge can no longer drop a
|
|
125
|
+
* closure that nobody reopened. */
|
|
126
|
+
function closureEvidence(r) {
|
|
127
|
+
let n = 0;
|
|
128
|
+
const lineage = r.lineage;
|
|
129
|
+
if (isRec(lineage)) {
|
|
130
|
+
for (const key of ["fixed_commit", "spawned_decision", "spawned_constraint"]) {
|
|
131
|
+
if (typeof lineage[key] === "string" && lineage[key].length > 0)
|
|
132
|
+
n += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const key of ["superseded_by", "valid_to"]) {
|
|
136
|
+
if (typeof r[key] === "string" && r[key].length > 0)
|
|
137
|
+
n += 1;
|
|
138
|
+
}
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
101
141
|
function parseSide(text) {
|
|
102
142
|
const trimmed = (text ?? "").trim();
|
|
103
143
|
if (!trimmed)
|
|
@@ -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.5",
|
|
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.",
|