@davesheffer/hunch 1.10.2 → 1.10.3

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
  * 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) {
@@ -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
@@ -890,10 +894,8 @@ export function buildServerWithRootControl(initialRoot) {
890
894
  return err(`Refusing to record public correction ${rec.id}: source decision ${rec.source_decision} ${location}.`);
891
895
  }
892
896
  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);
897
+ // Same cross-home twin guard as the decision path above.
898
+ store.putCapture("constraints", rec, !!input.private);
897
899
  store.reindex();
898
900
  // Propagate the new rule to EVERY assistant's ambient grounding (Cursor/Copilot/
899
901
  // 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";
@@ -1254,9 +1254,9 @@ export class HunchStore {
1254
1254
  // attribution (the strict guard fails on `blocking`).
1255
1255
  const ordered = [...decisions].sort((a, b) => Number(blockingDec.has(b.id)) - Number(blockingDec.has(a.id)));
1256
1256
  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")
1257
+ // Only IN-FORCE decisions: re-adding what an OUTDATED (superseded), REJECTED, or
1258
+ // window-closed decision removed is not a regression against the current design.
1259
+ if (!isInForce(d))
1260
1260
  continue;
1261
1261
  if (!d.retired.symbols.length && !d.retired.deps.length)
1262
1262
  continue;
@@ -1283,8 +1283,8 @@ export class HunchStore {
1283
1283
  const out = [];
1284
1284
  const seen = new Set(); // dedup: one hit per decision+alternative
1285
1285
  for (const d of this.recs("decisions")) {
1286
- if (d.superseded_by || d.status === "superseded")
1287
- continue; // in-force only
1286
+ if (!isInForce(d))
1287
+ continue; // in-force only (excludes rejected + window-closed)
1288
1288
  const tripwires = d.rejected_tripwires ?? [];
1289
1289
  if (!tripwires.length)
1290
1290
  continue;
@@ -1353,7 +1353,7 @@ export class HunchStore {
1353
1353
  retiredForFile(file) {
1354
1354
  const out = [];
1355
1355
  for (const d of this.recs("decisions")) {
1356
- if (d.superseded_by || d.status === "superseded")
1356
+ if (!isInForce(d))
1357
1357
  continue;
1358
1358
  if (!d.retired.symbols.length && !d.retired.deps.length)
1359
1359
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.10.2",
3
+ "version": "1.10.3",
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.",