@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.
@@ -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";
@@ -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.
@@ -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
- 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());
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
- let db = createDb(sqlitePath);
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
- if (!(error instanceof RebuildDerivedIndex)) {
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
- db.close();
75
- for (const path of [sqlitePath, `${sqlitePath}-wal`, `${sqlitePath}-shm`])
76
- rmSync(path, { force: true });
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
  }
@@ -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,
@@ -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
@@ -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 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.3",
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.",