@davesheffer/hunch 1.12.2 → 1.13.0

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/NOTICE CHANGED
@@ -1,4 +1,4 @@
1
- Hunch — Engineering Memory OS
1
+ Hunch — Engineering Memory and Change Gate
2
2
  Copyright 2026 Dave Sheffer
3
3
 
4
4
  This product is licensed under the Apache License, Version 2.0.
package/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
  [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
7
7
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
8
8
 
9
- Hunch is engineering memory for AI-assisted codebases. It records the decisions, constraints,
10
- rejected approaches, and bug history behind your code, then gives every connected assistant the
11
- relevant context before it makes a change.
9
+ Hunch is the engineering-memory and architectural-conformance layer for AI-assisted codebases. It
10
+ records the decisions, constraints, rejected approaches, and bug history behind your code, delivers
11
+ the relevant evidence before an assistant changes anything, then checks the result deterministically.
12
12
 
13
13
  Memory starts **advisory**. Nothing blocks until you explicitly trust a precise rule and choose
14
14
  strict enforcement.
@@ -16,9 +16,10 @@ strict enforcement.
16
16
  **Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
17
17
  then a deterministic check of the change against the rules your team has explicitly trusted.
18
18
 
19
- > **New in v1.9.4:** MCP connections stay collision-safe across repositories and simultaneous
20
- > captures, while generated MCP, hook, plugin, and CI commands pin the exact npm release that
21
- > created them.
19
+ > **New in v1.13.0:** CLI, MCP, and edit hooks now share one provenance-checked, hard-budgeted
20
+ > delivery envelope, and local receipts record exactly what reached the agent and why.
21
+
22
+ See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
22
23
 
23
24
  ## Start in five minutes
24
25
 
@@ -81,7 +82,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
81
82
  have one maintainer run:
82
83
 
83
84
  ```bash
84
- npm i -g @davesheffer/hunch@1.9.4
85
+ npm i -g @davesheffer/hunch@1.13.0
85
86
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
86
87
  git add .gitignore .hunch/team.json
87
88
  git commit -m "chore: connect shared Hunch memory"
@@ -96,7 +97,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
96
97
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
97
98
 
98
99
  ```bash
99
- npm i -g @davesheffer/hunch@1.9.4
100
+ npm i -g @davesheffer/hunch@1.13.0
100
101
  git pull
101
102
  hunch init
102
103
  hunch doctor
@@ -124,10 +125,8 @@ hunch policy card pol_...
124
125
  ```
125
126
 
126
127
  The upgrade creates evidence, a plan, and a proof but leaves the policy proposed with
127
- `authority: none`. In v1.9 its source-currentness activation gate is deliberately blocked, so even a
128
- human cannot activate an upgraded correction yet. Other proved policy types still require an
129
- explicit audited human acceptance before they can become advisory or blocking; Hunch never grants
130
- that authority automatically.
128
+ `authority: none`. A proved policy still requires explicit, audited human acceptance before it can
129
+ become advisory or blocking; Hunch never grants that authority automatically.
131
130
 
132
131
  Need to pause or roll back without deleting memory?
133
132
 
@@ -143,10 +142,9 @@ but stops automatic memory commits and pushes. As a team-coordinated rollback, r
143
142
  commit to stop discovery after teammates pull the revert. Existing machines retain their ignored
144
143
  local overlay until they are deliberately disconnected; do not delete the memory repo as part of a
145
144
  rollback. For this rollout, reinstall the previous published package with
146
- `npm i -g @davesheffer/hunch@1.8.5`; the release receipt resolves and records that exact rollback
147
- target from the npm registry instead of trusting Git tags. Version 1.8.5 fails closed when it sees a
148
- v1.9 source-gated correction policy, so pause enforcement first as shown above and upgrade every
149
- team client to v1.9 before resuming Matrix policy workflows.
145
+ `npm i -g @davesheffer/hunch@1.12.1`; the release receipt resolves and records the verified rollback
146
+ target from the npm registry instead of trusting Git tags. Pause enforcement first as shown above,
147
+ and keep every team client on the same release before resuming Matrix policy workflows.
150
148
 
151
149
  ## Synthesis without surprise billing
152
150
 
package/dist/cli/index.js CHANGED
@@ -52,6 +52,7 @@ import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integra
52
52
  import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding } from "../integrations/providers.js";
53
53
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
54
54
  import { formatContext, formatStructure } from "../core/format.js";
55
+ import { buildDeliveryEnvelope } from "../core/delivery.js";
55
56
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
56
57
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
57
58
  import { isHumanConfirmed } from "../core/strictgate.js";
@@ -69,7 +70,7 @@ import { computeDrift } from "../core/drift.js";
69
70
  import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
70
71
  import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
71
72
  import { adoptProsePrompt } from "../wiki/adopt.js";
72
- import { topicCollisions, renderGrounding, isInForce } from "../core/topics.js";
73
+ import { topicCollisions, isInForce } from "../core/topics.js";
73
74
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
74
75
  import { premiseEscalations } from "../core/premises.js";
75
76
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
@@ -92,7 +93,7 @@ import { planCompaction } from "../store/compact.js";
92
93
  import { repairDecisionReference } from "../core/refrepair.js";
93
94
  import { resolveInvocation, dim, synthesisStatusLines, maybeWarnOllamaContext } from "./invocation.js";
94
95
  const program = new Command();
95
- program.name("hunch").description("Hunch — an Engineering Memory OS: a git-native reasoning graph for your codebase.").version(HUNCH_VERSION);
96
+ program.name("hunch").description("Hunch — engineering memory and a deterministic Change Gate for AI-assisted codebases.").version(HUNCH_VERSION);
96
97
  let openStore = null;
97
98
  function openTeamStore(root, opts = {}) {
98
99
  // A committed team.json is an explicit declaration that this checkout belongs
@@ -3518,7 +3519,13 @@ program
3518
3519
  return;
3519
3520
  }
3520
3521
  }
3521
- process.stdout.write(formatContext(ctx));
3522
+ process.stdout.write(formatContext(ctx, {
3523
+ root,
3524
+ symbols: store.recs("symbols"),
3525
+ components: store.recs("components"),
3526
+ decisionCorpus: store.recs("decisions"),
3527
+ historical: !!asOf,
3528
+ }));
3522
3529
  store.close();
3523
3530
  });
3524
3531
  // ---- timeline -------------------------------------------------------------
@@ -3746,7 +3753,9 @@ program
3746
3753
  };
3747
3754
  console.log(" Most delivered:");
3748
3755
  for (const row of summary.rows.slice(0, 10)) {
3749
- console.log(` ${String(row.serves).padStart(4)}× (+${row.refreshes} still-current) ${row.record_id} ${titleOf(row)}`);
3756
+ const delivery = row.best_rank == null ? "" : ` · best rank ${row.best_rank}`;
3757
+ const cost = row.average_token_cost == null ? "" : ` · ~${row.average_token_cost} tokens`;
3758
+ console.log(` ${String(row.serves).padStart(4)}× (+${row.refreshes} still-current) ${row.record_id}${delivery}${cost} — ${titleOf(row)}`);
3750
3759
  }
3751
3760
  const servedIds = new Set(summary.rows.map((r) => r.record_id));
3752
3761
  const neverServed = [
@@ -3871,8 +3880,9 @@ program
3871
3880
  return;
3872
3881
  L.push(`🧠 Hunch — repo shape for a delegated explorer: ${components.length} component(s).`);
3873
3882
  for (const c of components.slice(0, 12)) {
3874
- L.push(`- ${c.name}${c.paths.length ? ` (${c.paths.slice(0, 2).join(", ")})` : ""}${c.responsibility ? ` — ${clip1(c.responsibility, 90)}` : ""}`);
3875
- served.push({ kind: "components", record_id: c.id });
3883
+ const line = `- ${c.name}${c.paths.length ? ` (${c.paths.slice(0, 2).join(", ")})` : ""}${c.responsibility ? ` — ${clip1(c.responsibility, 90)}` : ""}`;
3884
+ L.push(line);
3885
+ served.push({ kind: "components", record_id: c.id, token_cost: Math.ceil([...line].length / 4) });
3876
3886
  }
3877
3887
  if (components.length > 12)
3878
3888
  L.push(`…and ${components.length - 12} more — hunch_structure() for the full map.`);
@@ -3887,8 +3897,9 @@ program
3887
3897
  return;
3888
3898
  L.push(`🧠 Hunch — live decisions for a delegated planner (${decisions.length} in force; plans must not re-propose the rejected).`);
3889
3899
  for (const d of decisions.slice(0, 6)) {
3890
- L.push(`- ${d.title} (${d.id})${d.alternatives_rejected.length ? ` — rejected: ${clip1(d.alternatives_rejected[0], 80)}` : ""}`);
3891
- served.push({ kind: "decisions", record_id: d.id });
3900
+ const line = `- ${d.title} (${d.id})${d.alternatives_rejected.length ? ` — rejected: ${clip1(d.alternatives_rejected[0], 80)}` : ""}`;
3901
+ L.push(line);
3902
+ served.push({ kind: "decisions", record_id: d.id, token_cost: Math.ceil([...line].length / 4) });
3892
3903
  }
3893
3904
  L.push("Before finalizing a plan: hunch_why(target) · hunch_current_decision(topic) · hunch_check_constraints(scope).");
3894
3905
  }
@@ -3901,8 +3912,9 @@ program
3901
3912
  return;
3902
3913
  L.push(`🧠 Hunch — delegated agent grounding: ${constraints.length} invariant(s) in force in this repo.`);
3903
3914
  for (const c of constraints.slice(0, 8)) {
3904
- L.push(`- [${c.severity}] ${clip1(c.statement, 140)}${c.scope.length ? ` (scope: ${c.scope.slice(0, 3).join(", ")})` : ""}`);
3905
- served.push({ kind: "constraints", record_id: c.id });
3915
+ const line = `- [${c.severity}] ${clip1(c.statement, 140)}${c.scope.length ? ` (scope: ${c.scope.slice(0, 3).join(", ")})` : ""}`;
3916
+ L.push(line);
3917
+ served.push({ kind: "constraints", record_id: c.id, token_cost: Math.ceil([...line].length / 4) });
3906
3918
  }
3907
3919
  if (constraints.length > 8)
3908
3920
  L.push(`…and ${constraints.length - 8} more — hunch_check_constraints(scope) for your files.`);
@@ -3911,7 +3923,15 @@ program
3911
3923
  // No dedup here: the hook event carries the PARENT session id, but each
3912
3924
  // spawned agent is a fresh empty context — deduping would ground the
3913
3925
  // first Explore and silently starve every later one.
3914
- recordServed(root, served.map((r) => ({ ...r, event: "served", target: `(subagent:${evt.agent_type ?? "any"})`, session_id: evt.session_id })));
3926
+ recordServed(root, served.map((r, index) => ({
3927
+ ...r,
3928
+ event: "served",
3929
+ target: `(subagent:${evt.agent_type ?? "any"})`,
3930
+ session_id: evt.session_id,
3931
+ rank: index + 1,
3932
+ delivery_reason: "ranked",
3933
+ provenance_status: "unverified",
3934
+ })));
3915
3935
  emitContext(provider, "SubagentStart", L.join("\n"));
3916
3936
  }
3917
3937
  finally {
@@ -4058,40 +4078,42 @@ program
4058
4078
  const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || ctx.findings.length || retired.length || docGround;
4059
4079
  if (!hasContent)
4060
4080
  return; // no noise on files Hunch hasn't learned yet
4061
- let text = formatContext(ctx).trim();
4062
- if (firmness !== "advisory" && ctx.constraints.length) {
4063
- const names = ctx.constraints.map((c) => `[${c.severity}] ${c.statement}`).join("; ");
4064
- text += `\n\n⚠ This file is in scope of ${ctx.constraints.length} invariant(s): ${names}. Preserve them.`;
4065
- }
4066
- if (retired.length) {
4067
- const items = retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ");
4068
- text += `\n\n⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${items}.`;
4069
- }
4070
- // Decision-grounding (§3): for topic-anchored decisions governing this file, state
4071
- // the current decision assertively (graph over any stale doc) + what it rejected.
4072
- // The FULL decision set is passed alongside the file slice so a topic contested
4073
- // somewhere else in the graph is reported as unresolved instead of being asserted
4074
- // as settled — the collision's two sides often live in different files.
4075
- const grounding = renderGrounding(ctx.decisions, store.recs("decisions"));
4076
- if (grounding)
4077
- text += `\n\n${grounding}`;
4078
- if (docGround)
4079
- text += `\n\n${docGround}`;
4081
+ const envelope = buildDeliveryEnvelope(ctx, {
4082
+ root,
4083
+ symbols: store.recs("symbols"),
4084
+ components: store.recs("components"),
4085
+ decisionCorpus: store.recs("decisions"),
4086
+ supplements: [
4087
+ ...(retired.length ? [{
4088
+ id: "retired-code",
4089
+ kind: "retired-code",
4090
+ priority: 200,
4091
+ text: `⚠ Deliberately RETIRED from this file do not re-introduce without cause: ${retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ")}.`,
4092
+ }] : []),
4093
+ ...(docGround ? [{ id: "doc-grounding", kind: "doc-grounding", priority: 100, text: docGround }] : []),
4094
+ ],
4095
+ });
4096
+ const text = envelope.text.trim();
4080
4097
  // Identical grounding already shown this session → one-line delta instead of
4081
4098
  // the full 10-16KB block. Any record change re-sends the full text; the
4082
4099
  // strict-gate deny path above never routes through this (dec_244397d920).
4083
4100
  // Delivery receipts (dec_925f4bcaad): the ledger of what actually reached
4084
4101
  // an agent. A full injection is a serve; a delta one-liner attests the
4085
4102
  // earlier serve is still standing. Never throws, never blocks.
4086
- const receipts = (event) => recordServed(root, [
4087
- ...ctx.constraints.map((c) => ({ event, kind: "constraints", record_id: c.id, target, session_id: evt.session_id })),
4088
- ...ctx.decisions.map((d) => ({ event, kind: "decisions", record_id: d.id, target, session_id: evt.session_id })),
4089
- ...ctx.bugs.map((b) => ({ event, kind: "bugs", record_id: b.id, target, session_id: evt.session_id })),
4090
- ...ctx.findings.map((f) => ({ event, kind: "findings", record_id: f.id, target, session_id: evt.session_id })),
4091
- ]);
4103
+ const receipts = (event) => recordServed(root, envelope.delivered.map((item) => ({
4104
+ event,
4105
+ kind: item.kind,
4106
+ record_id: item.record_id,
4107
+ target,
4108
+ session_id: evt.session_id,
4109
+ rank: item.rank,
4110
+ delivery_reason: item.delivery_reason,
4111
+ provenance_status: item.provenance_status,
4112
+ token_cost: item.token_cost,
4113
+ })));
4092
4114
  if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
4093
4115
  receipts("refreshed");
4094
- emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${ctx.decisions.length} decision(s), ${ctx.constraints.length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
4116
+ emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${envelope.delivered.filter((item) => item.kind === "decisions").length} decision(s), ${envelope.delivered.filter((item) => item.kind === "constraints").length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
4095
4117
  return;
4096
4118
  }
4097
4119
  receipts("served");
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Deterministic delivery envelope for agent-facing memory.
3
+ *
4
+ * Retrieval answers "what may be relevant". This layer answers the separate,
5
+ * observable question "what was actually safe and small enough to deliver".
6
+ * Keeping that boundary explicit makes delivery receipts truthful: callers
7
+ * receipt only `delivered`, never every record returned by retrieval.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync } from "node:fs";
11
+ import { isAbsolute, join } from "node:path";
12
+ import { pathMatchesGlob, pathsRelated } from "./glob.js";
13
+ import { toPosixTarget } from "./paths.js";
14
+ import { renderGrounding } from "./topics.js";
15
+ const SEVERITY = { advisory: 1, warning: 2, blocking: 3, low: 1, medium: 2, high: 3, critical: 4 };
16
+ function clipHeadline(value, max) {
17
+ const flat = value.replace(/\s+/g, " ").trim();
18
+ if (flat.length <= max)
19
+ return flat;
20
+ return `${flat.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
21
+ }
22
+ function sourceTier(source) {
23
+ const parts = (source ?? "").split("+");
24
+ if (parts.includes("human_confirmed"))
25
+ return "human";
26
+ if (parts.includes("agent_recorded"))
27
+ return "agent";
28
+ if (parts.includes("llm_draft"))
29
+ return "model";
30
+ return source || "unknown";
31
+ }
32
+ function isSafeDeliveryAnchor(value) {
33
+ const path = toPosixTarget(value);
34
+ const segments = path.split("/");
35
+ return !!path
36
+ && !isAbsolute(path)
37
+ && !/^[a-zA-Z]:/.test(path)
38
+ && segments.every((segment) => !!segment && segment !== "." && segment !== ".." && segment.toLowerCase() !== ".git");
39
+ }
40
+ function anchorResolves(anchor, target, options) {
41
+ const normalized = toPosixTarget(anchor);
42
+ if (!isSafeDeliveryAnchor(normalized))
43
+ return false;
44
+ const hasGlob = /[*?]/.test(normalized);
45
+ if (!hasGlob && options.root && existsSync(join(options.root, normalized)))
46
+ return true;
47
+ if (hasGlob && options.root && existsSync(join(options.root, toPosixTarget(target))) && pathMatchesGlob(target, normalized))
48
+ return true;
49
+ if (!hasGlob && pathsRelated(normalized, target) && options.root && existsSync(join(options.root, toPosixTarget(target))))
50
+ return true;
51
+ return (options.symbols ?? []).some((symbol) => pathMatchesGlob(symbol.file, normalized) || (!hasGlob && pathsRelated(symbol.file, normalized)));
52
+ }
53
+ function symbolResolves(anchor, target, options) {
54
+ return anchor === target || (options.symbols ?? []).some((symbol) => symbol.id === anchor || symbol.name === anchor);
55
+ }
56
+ function componentResolves(id, target, options) {
57
+ const component = (options.components ?? []).find((candidate) => candidate.id === id && candidate.status === "active");
58
+ return !!component && component.paths.some((path) => anchorResolves(path, target, options));
59
+ }
60
+ function gitProbe(root, args) {
61
+ const result = spawnSync("git", ["-C", root, ...args], {
62
+ encoding: "utf8",
63
+ env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
64
+ stdio: ["ignore", "pipe", "ignore"],
65
+ timeout: 2_000,
66
+ windowsHide: true,
67
+ });
68
+ return { available: !result.error, status: result.status, stdout: result.stdout?.trim() ?? "" };
69
+ }
70
+ function defaultBranchRef(root) {
71
+ const remote = gitProbe(root, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
72
+ if (!remote.available)
73
+ return null;
74
+ if (remote.status === 0 && remote.stdout)
75
+ return remote.stdout;
76
+ for (const ref of ["refs/heads/main", "refs/heads/master", "HEAD"]) {
77
+ const probe = gitProbe(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
78
+ if (!probe.available)
79
+ return null;
80
+ if (probe.status === 0)
81
+ return ref;
82
+ }
83
+ return null;
84
+ }
85
+ function localCommitReachability(root) {
86
+ let initialized = false;
87
+ let branch = null;
88
+ return (commit) => {
89
+ if (!initialized) {
90
+ branch = defaultBranchRef(root);
91
+ initialized = true;
92
+ }
93
+ if (!branch)
94
+ return "unknown";
95
+ if (!/^[0-9a-f]{7,64}$/i.test(commit))
96
+ return "unreachable";
97
+ const exact = gitProbe(root, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]);
98
+ if (!exact.available)
99
+ return "unknown";
100
+ if (exact.status !== 0)
101
+ return "unreachable";
102
+ const ancestry = gitProbe(root, ["merge-base", "--is-ancestor", exact.stdout, branch]);
103
+ if (!ancestry.available)
104
+ return "unknown";
105
+ if (ancestry.status === 0)
106
+ return "reachable";
107
+ if (ancestry.status === 1)
108
+ return "unreachable";
109
+ return "unknown";
110
+ };
111
+ }
112
+ function validationState(anchors, anchorsResolve, commit, reachability, requireCommit = false, canValidateAnchors = true) {
113
+ if (anchors.length && canValidateAnchors && !anchors.some(anchorsResolve)) {
114
+ return { state: "stale", detail: "no recorded file/symbol/component anchor resolves in the current workspace/index" };
115
+ }
116
+ if (requireCommit && commit) {
117
+ const reachable = reachability(commit);
118
+ if (reachable === "unreachable")
119
+ return { state: "stale", detail: `capture commit ${commit} is not reachable from the default branch` };
120
+ if (reachable === "unknown")
121
+ return { state: "unverified", detail: `capture commit ${commit} could not be checked` };
122
+ }
123
+ if (!anchors.length || !canValidateAnchors || (requireCommit && (commit === null || commit === undefined))) {
124
+ return { state: "unverified", detail: requireCommit
125
+ ? "record has no complete file/symbol + commit provenance pair"
126
+ : "record has no resolvable file/symbol provenance anchor" };
127
+ }
128
+ return { state: "current", detail: requireCommit ? "anchors resolve and capture commit is reachable" : "anchors resolve" };
129
+ }
130
+ function fitText(text, cap) {
131
+ if (cap <= 0)
132
+ return "";
133
+ const chars = [...text];
134
+ if (chars.length <= cap)
135
+ return text;
136
+ return chars.slice(0, cap).join("");
137
+ }
138
+ function charCount(text) {
139
+ return [...text].length;
140
+ }
141
+ function estimatedTokens(text) {
142
+ return Math.max(1, Math.ceil(charCount(text) / 4));
143
+ }
144
+ /** Build the one envelope used by CLI, MCP, and the edit hook. */
145
+ export function buildDeliveryEnvelope(ctx, options = {}) {
146
+ const budget = Number.isFinite(ctx.budget_tokens) ? Math.max(0, Math.floor(ctx.budget_tokens)) : 1500;
147
+ const cap = budget * 4;
148
+ const reachability = options.commitReachability
149
+ ?? (options.root ? localCommitReachability(options.root) : (() => "unknown"));
150
+ const canValidateAnchors = options.root !== undefined || options.symbols !== undefined || options.components !== undefined;
151
+ const candidates = [];
152
+ for (const constraint of ctx.constraints) {
153
+ const retired = !options.historical && (constraint.status === "retired" || constraint.valid_to != null);
154
+ const validation = validationState(constraint.scope, (anchor) => anchorResolves(anchor, ctx.target, options), undefined, reachability, false, canValidateAnchors);
155
+ candidates.push({
156
+ ref: { kind: "constraints", record_id: constraint.id },
157
+ mandatory: !retired && constraint.severity === "blocking",
158
+ score: 900 + SEVERITY[constraint.severity] * 10 + (constraint.provenance.confidence ?? 0),
159
+ provenance: validation.state,
160
+ staleDetail: validation.detail,
161
+ retiredDetail: retired ? "constraint is retired at HEAD" : undefined,
162
+ line: `${constraint.id} | constraint/${constraint.severity} | ${clipHeadline(constraint.statement, 180)} | scope ${clipHeadline(constraint.scope.join(", ") || "repo", 100)} | ${sourceTier(constraint.provenance.source)}/${validation.state} | hunch_why("${constraint.id}")`,
163
+ });
164
+ }
165
+ for (const decision of ctx.decisions) {
166
+ const retired = !options.historical && (decision.status === "rejected" || decision.status === "superseded" || !!decision.superseded_by || decision.valid_to != null);
167
+ const fileAnchors = decision.related_files;
168
+ const componentAnchors = decision.related_components;
169
+ const validation = validationState([...fileAnchors, ...componentAnchors], (anchor) => fileAnchors.includes(anchor)
170
+ ? anchorResolves(anchor, ctx.target, options)
171
+ : componentResolves(anchor, ctx.target, options), decision.commit, reachability, true, canValidateAnchors);
172
+ candidates.push({
173
+ ref: { kind: "decisions", record_id: decision.id },
174
+ mandatory: false,
175
+ score: 700 + (decision.status === "accepted" ? 20 : 0) + (decision.provenance.confidence ?? 0),
176
+ provenance: validation.state,
177
+ staleDetail: validation.detail,
178
+ retiredDetail: retired ? `decision is ${decision.status} at HEAD` : undefined,
179
+ line: `${decision.id} | decision/${decision.status} | ${clipHeadline(`${decision.title}: ${decision.decision}`, 220)} | scope ${clipHeadline(decision.related_files.join(", ") || decision.related_components.join(", ") || "unanchored", 100)} | ${sourceTier(decision.provenance.source)}/${validation.state} | hunch_why("${decision.id}")`,
180
+ });
181
+ }
182
+ for (const bug of ctx.bugs) {
183
+ const anchors = [...bug.affected_files, ...bug.affected_symbols];
184
+ const validation = validationState(anchors, (anchor) => bug.affected_files.includes(anchor)
185
+ ? anchorResolves(anchor, ctx.target, options)
186
+ : symbolResolves(anchor, ctx.target, options), undefined, reachability, false, canValidateAnchors);
187
+ candidates.push({
188
+ ref: { kind: "bugs", record_id: bug.id },
189
+ mandatory: false,
190
+ score: 800 + SEVERITY[bug.severity] * 10 + (bug.status === "open" || bug.status === "regressed" ? 10 : 0),
191
+ provenance: validation.state,
192
+ staleDetail: validation.detail,
193
+ line: `${bug.id} | bug/${bug.status}/${bug.severity} | ${clipHeadline(`${bug.title} — root cause: ${bug.root_cause}`, 220)} | ${sourceTier(bug.provenance.source)}/${validation.state} | hunch_why("${bug.id}")`,
194
+ });
195
+ }
196
+ for (const finding of ctx.findings) {
197
+ const anchors = [...finding.affected_files, ...finding.affected_symbols];
198
+ const validation = validationState(anchors, (anchor) => finding.affected_files.includes(anchor)
199
+ ? anchorResolves(anchor, ctx.target, options)
200
+ : symbolResolves(anchor, ctx.target, options), undefined, reachability, false, canValidateAnchors);
201
+ const evidence = `${finding.violates_constraint ? `; violates ${finding.violates_constraint}` : ""}${finding.method ? `; re-verify via ${finding.method}` : ""}`;
202
+ candidates.push({
203
+ ref: { kind: "findings", record_id: finding.id },
204
+ mandatory: false,
205
+ score: 600 + SEVERITY[finding.severity] * 10,
206
+ provenance: validation.state,
207
+ staleDetail: validation.detail,
208
+ line: `${finding.id} | finding/${finding.triage}/${finding.severity} | ${clipHeadline(`${finding.title} — ${finding.observation}${evidence}`, 240)} | ${sourceTier(finding.provenance.source)}/${validation.state} | hunch_why("${finding.id}")`,
209
+ });
210
+ }
211
+ for (const dependent of ctx.blast_radius) {
212
+ candidates.push({ mandatory: false, score: 400 - dependent.depth, provenance: "current", line: `graph | blast/d${dependent.depth} | ${clipHeadline(dependent.via, 220)}` });
213
+ }
214
+ if (ctx.components.length) {
215
+ candidates.push({ mandatory: false, score: 300, provenance: "current", line: `graph | components | ${clipHeadline(ctx.components.map((component) => component.name).join(", "), 240)}` });
216
+ }
217
+ const hasAnything = candidates.length > 0 || (options.supplements?.length ?? 0) > 0;
218
+ if (!hasAnything) {
219
+ const empty = `# Hunch context for "${ctx.target}"\n\n(No recorded constraints/decisions/bugs for this target yet — Hunch is still learning it.)\n`;
220
+ const text = fitText(empty, cap);
221
+ return { text, delivered: [], supplements: [], omitted: [], budget_tokens: budget, used_chars: charCount(text), blocking_overflow: false };
222
+ }
223
+ const omitted = [];
224
+ const eligible = [];
225
+ for (const candidate of candidates) {
226
+ if (candidate.retiredDetail && candidate.ref) {
227
+ omitted.push({ ...candidate.ref, reason: "retired", detail: candidate.retiredDetail });
228
+ continue;
229
+ }
230
+ if (candidate.provenance === "stale" && !candidate.mandatory && candidate.ref) {
231
+ omitted.push({ ...candidate.ref, reason: "stale-provenance", detail: candidate.staleDetail ?? "provenance is stale" });
232
+ continue;
233
+ }
234
+ eligible.push(candidate);
235
+ }
236
+ eligible.sort((left, right) => Number(right.mandatory) - Number(left.mandatory) || right.score - left.score || (left.ref?.record_id ?? left.line).localeCompare(right.ref?.record_id ?? right.line));
237
+ const recordCandidates = eligible.filter((candidate) => candidate.ref);
238
+ const structuralCandidates = eligible.filter((candidate) => !candidate.ref);
239
+ const lines = [
240
+ `# Hunch context for "${ctx.target}"`,
241
+ "",
242
+ "## 🧠 Ranked memory (Invariants · Decisions · Bugs · Known findings)",
243
+ ];
244
+ let text = `${lines.join("\n")}\n`;
245
+ const delivered = [];
246
+ const supplements = [];
247
+ let blockingOverflow = false;
248
+ for (const [index, candidate] of recordCandidates.entries()) {
249
+ const next = `- ${candidate.line}\n`;
250
+ if (charCount(text) + charCount(next) <= cap || candidate.mandatory) {
251
+ text += next;
252
+ delivered.push({
253
+ ...candidate.ref,
254
+ rank: index + 1,
255
+ delivery_reason: candidate.mandatory ? "blocking-reserved" : "ranked",
256
+ provenance_status: candidate.provenance,
257
+ token_cost: estimatedTokens(next),
258
+ });
259
+ if (charCount(text) > cap && candidate.mandatory)
260
+ blockingOverflow = true;
261
+ }
262
+ else if (candidate.ref) {
263
+ omitted.push({ ...candidate.ref, reason: "budget", detail: `ranked headline did not fit the ${budget}-token budget` });
264
+ }
265
+ }
266
+ const deliveredDecisionIds = new Set(delivered.filter((item) => item.kind === "decisions").map((item) => item.record_id));
267
+ const deliveredDecisions = ctx.decisions.filter((decision) => deliveredDecisionIds.has(decision.id));
268
+ const decisionGrounding = options.decisionCorpus?.length && deliveredDecisions.length
269
+ ? renderGrounding(deliveredDecisions, [...options.decisionCorpus])
270
+ : "";
271
+ const supplementalCandidates = [
272
+ ...(decisionGrounding ? [{ id: "decision-grounding", kind: "decision-grounding", text: decisionGrounding, priority: 1_000 }] : []),
273
+ ...(options.supplements ?? []),
274
+ ].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.id.localeCompare(right.id));
275
+ for (const [index, supplement] of supplementalCandidates.entries()) {
276
+ const content = clipHeadline(supplement.text, 700);
277
+ if (!content) {
278
+ supplements.push({ id: supplement.id, kind: supplement.kind, delivered: false, reason: "empty", rank: index + 1, token_cost: 0 });
279
+ continue;
280
+ }
281
+ const next = `- supplemental/${supplement.kind} | ${content}\n`;
282
+ const tokenCost = estimatedTokens(next);
283
+ if (charCount(text) + charCount(next) <= cap) {
284
+ text += next;
285
+ supplements.push({ id: supplement.id, kind: supplement.kind, delivered: true, reason: "supplemental", rank: index + 1, token_cost: tokenCost });
286
+ }
287
+ else {
288
+ supplements.push({ id: supplement.id, kind: supplement.kind, delivered: false, reason: "budget", rank: index + 1, token_cost: tokenCost });
289
+ }
290
+ }
291
+ for (const candidate of structuralCandidates) {
292
+ const next = `- ${candidate.line}\n`;
293
+ if (charCount(text) + charCount(next) <= cap)
294
+ text += next;
295
+ }
296
+ const staleCount = omitted.filter((item) => item.reason === "stale-provenance" || item.reason === "retired").length;
297
+ const budgetCount = omitted.filter((item) => item.reason === "budget").length;
298
+ const notes = [
299
+ staleCount ? `${staleCount} stale/retired record(s) withheld; run hunch drift or hunch_why(id) to inspect.` : "",
300
+ budgetCount ? `${budgetCount} lower-ranked record(s) omitted by budget; use hunch_why(id) to drill down.` : "",
301
+ ].filter(Boolean);
302
+ if (notes.length) {
303
+ const footer = `… ${notes.join(" ")}\n`;
304
+ if (charCount(text) + charCount(footer) <= cap)
305
+ text += footer;
306
+ }
307
+ if (!text.endsWith("\n") && charCount(text) < cap)
308
+ text += "\n";
309
+ if (!blockingOverflow)
310
+ text = fitText(text, cap);
311
+ omitted.sort((left, right) => left.record_id.localeCompare(right.record_id) || left.reason.localeCompare(right.reason));
312
+ return {
313
+ text,
314
+ delivered,
315
+ supplements,
316
+ omitted,
317
+ budget_tokens: budget,
318
+ used_chars: charCount(text),
319
+ blocking_overflow: blockingOverflow,
320
+ };
321
+ }
322
+ //# sourceMappingURL=delivery.js.map
@@ -1,53 +1,6 @@
1
- function prov(p) {
2
- if (!p)
3
- return "";
4
- const v = p.last_verified ? `, verified ${p.last_verified.slice(0, 10)}` : "";
5
- return ` ⟨${p.source ?? "?"} ${p.confidence ?? "?"}${v}⟩`;
6
- }
7
- export function formatContext(ctx) {
8
- const out = [`# Hunch context for "${ctx.target}"`];
9
- if (ctx.constraints.length) {
10
- out.push(`\n## ⛔ Invariants (must not break)`);
11
- for (const c of ctx.constraints)
12
- out.push(`- [${c.severity}] ${c.statement}${prov(c.provenance)}\n (${c.id}; scope ${c.scope.join(", ") || "repo"})`);
13
- }
14
- if (ctx.decisions.length) {
15
- out.push(`\n## 🧭 Decisions (why it's shaped this way)`);
16
- for (const d of ctx.decisions)
17
- out.push(`- [${d.status}] ${d.title}${prov(d.provenance)}\n ${d.decision}`);
18
- }
19
- if (ctx.bugs.length) {
20
- out.push(`\n## 🐞 Bug history (don't reintroduce)`);
21
- for (const b of ctx.bugs)
22
- out.push(`- [${b.status}/${b.severity}] ${b.title} — root cause: ${b.root_cause}${prov(b.provenance)}`);
23
- }
24
- if (ctx.findings.length) {
25
- out.push(`\n## 🔍 Known findings (observed, unresolved — no fix landed yet)`);
26
- for (const f of ctx.findings) {
27
- out.push(`- [${f.triage}/${f.severity}] ${f.title} — ${f.observation}${prov(f.provenance)}\n (${f.id}; observed ${f.observed_at.slice(0, 10)}${f.violates_constraint ? `; violates ${f.violates_constraint}` : ""}${f.method ? `; re-verify via ${f.method}` : ""})`);
28
- }
29
- }
30
- if (ctx.blast_radius.length) {
31
- out.push(`\n## 💥 Blast radius (transitive dependents)`);
32
- out.push(ctx.blast_radius.map((d) => `- [d${d.depth}] ${d.via}`).join("\n"));
33
- }
34
- if (ctx.components.length)
35
- out.push(`\n## 📦 Components: ${ctx.components.map((c) => c.name).join(", ")}`);
36
- if (out.length === 1)
37
- out.push(`\n(No recorded constraints/decisions/bugs for this target yet — Hunch is still learning it.)`);
38
- // crude budget trim: ~4 chars/token. Slice on code points (not UTF-16 units)
39
- // and back off to the last line boundary so we never split a surrogate pair or
40
- // a record mid-line.
41
- const text = out.join("\n");
42
- const cap = ctx.budget_tokens * 4;
43
- const chars = [...text];
44
- if (chars.length <= cap)
45
- return text + "\n";
46
- let trimmed = chars.slice(0, cap).join("");
47
- const lastNl = trimmed.lastIndexOf("\n");
48
- if (lastNl > cap * 0.5)
49
- trimmed = trimmed.slice(0, lastNl);
50
- return trimmed + "\n… (trimmed to budget)\n";
1
+ import { buildDeliveryEnvelope } from "./delivery.js";
2
+ export function formatContext(ctx, options = {}) {
3
+ return buildDeliveryEnvelope(ctx, options).text;
51
4
  }
52
5
  /** Render a StructureView as a compact orientation brief (hunch_structure). */
53
6
  export function formatStructure(v) {
package/dist/core/glob.js CHANGED
@@ -59,4 +59,11 @@ export function pathMatchesGlob(path, glob) {
59
59
  return true;
60
60
  return false;
61
61
  }
62
+ /** Do two concrete repo paths identify the same path at different prefix depth?
63
+ * Segment-anchored: `x/scenario.ts` relates to `scenario.ts`, never `io.ts`. */
64
+ export function pathsRelated(left, right) {
65
+ const a = norm(left);
66
+ const b = norm(right);
67
+ return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
68
+ }
62
69
  //# sourceMappingURL=glob.js.map
@@ -36,6 +36,34 @@ function loadSqlite() {
36
36
  }
37
37
  }
38
38
  let sqlite = null;
39
+ const RECEIPT_COLUMNS = [
40
+ ["rank", "INTEGER"],
41
+ ["delivery_reason", "TEXT"],
42
+ ["provenance_status", "TEXT"],
43
+ ["token_cost", "INTEGER"],
44
+ ];
45
+ function columnNames(db) {
46
+ const rows = db.prepare("PRAGMA table_info(served)").all();
47
+ return new Set(rows.map((row) => row.name));
48
+ }
49
+ /** Additive, idempotent migration for machine-local ledgers created by older releases. */
50
+ function ensureReceiptColumns(db) {
51
+ const existing = columnNames(db);
52
+ for (const [name, type] of RECEIPT_COLUMNS) {
53
+ if (existing.has(name))
54
+ continue;
55
+ try {
56
+ db.exec(`ALTER TABLE served ADD COLUMN ${name} ${type}`);
57
+ }
58
+ catch (error) {
59
+ // Two hook processes may race the same migration. Only swallow when the
60
+ // other process demonstrably completed this exact additive change.
61
+ if (!columnNames(db).has(name))
62
+ throw error;
63
+ }
64
+ existing.add(name);
65
+ }
66
+ }
39
67
  function openServedDb(root) {
40
68
  sqlite ??= loadSqlite();
41
69
  const dir = join(root, ".hunch-cache");
@@ -50,6 +78,7 @@ function openServedDb(root) {
50
78
  target TEXT NOT NULL
51
79
  );
52
80
  CREATE INDEX IF NOT EXISTS served_record ON served (record_id);`);
81
+ ensureReceiptColumns(db);
53
82
  return db;
54
83
  }
55
84
  /** Append delivery receipts. Never throws — a receipt must never cost a delivery. */
@@ -60,9 +89,9 @@ export function recordServed(root, entries) {
60
89
  const db = openServedDb(root);
61
90
  try {
62
91
  const at = new Date().toISOString();
63
- const insert = db.prepare("INSERT INTO served (at, session, event, kind, record_id, target) VALUES (?, ?, ?, ?, ?, ?)");
92
+ const insert = db.prepare("INSERT INTO served (at, session, event, kind, record_id, target, rank, delivery_reason, provenance_status, token_cost) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
64
93
  for (const entry of entries) {
65
- insert.run(at, entry.session_id ?? null, entry.event, entry.kind, entry.record_id, entry.target);
94
+ insert.run(at, entry.session_id ?? null, entry.event, entry.kind, entry.record_id, entry.target, entry.rank ?? null, entry.delivery_reason ?? null, entry.provenance_status ?? null, entry.token_cost ?? null);
66
95
  }
67
96
  }
68
97
  finally {
@@ -75,16 +104,23 @@ export function recordServed(root, entries) {
75
104
  }
76
105
  /** The ledger, aggregated per record. Never throws; an unreadable ledger reads as empty. */
77
106
  export function servedSummary(root) {
78
- const empty = { total: 0, distinct_records: 0, distinct_sessions: 0, first_at: null, last_at: null, rows: [] };
107
+ const empty = { total: 0, distinct_records: 0, distinct_sessions: 0, first_at: null, last_at: null, rows: [], recent: [] };
79
108
  try {
80
109
  const db = openServedDb(root);
81
110
  try {
82
111
  const totals = db.prepare("SELECT COUNT(*) AS total, COUNT(DISTINCT record_id) AS records, COUNT(DISTINCT session) AS sessions, MIN(at) AS first_at, MAX(at) AS last_at FROM served").get();
83
- const rows = db.prepare(`SELECT record_id, kind,
112
+ const rawRows = db.prepare(`SELECT record_id, kind,
84
113
  SUM(CASE WHEN event = 'served' THEN 1 ELSE 0 END) AS serves,
85
114
  SUM(CASE WHEN event = 'refreshed' THEN 1 ELSE 0 END) AS refreshes,
86
- MAX(at) AS last_at
115
+ MAX(at) AS last_at,
116
+ MIN(rank) AS best_rank,
117
+ ROUND(AVG(token_cost), 2) AS average_token_cost
87
118
  FROM served GROUP BY record_id, kind ORDER BY serves DESC, refreshes DESC`).all();
119
+ const rawRecent = db.prepare(`SELECT at, session AS session_id, event, kind, record_id, target,
120
+ rank, delivery_reason, provenance_status, token_cost
121
+ FROM served ORDER BY rowid DESC LIMIT 50`).all();
122
+ const rows = rawRows.map((row) => ({ ...row }));
123
+ const recent = rawRecent.map((receipt) => ({ ...receipt }));
88
124
  return {
89
125
  total: totals?.total ?? 0,
90
126
  distinct_records: totals?.records ?? 0,
@@ -92,6 +128,7 @@ export function servedSummary(root) {
92
128
  first_at: totals?.first_at ?? null,
93
129
  last_at: totals?.last_at ?? null,
94
130
  rows,
131
+ recent,
95
132
  };
96
133
  }
97
134
  finally {
@@ -605,7 +605,13 @@ export function buildServerWithRootControl(initialRoot) {
605
605
  return ok(`No file/symbol resolves for "${target}" — closest graph matches instead:\n\n${lines.join("\n")}\n\n(For a file/symbol brief pass a concrete target; free-text goes through the same search as hunch_query.)`);
606
606
  }
607
607
  }
608
- return ok(formatContext(ctx));
608
+ return ok(formatContext(ctx, {
609
+ root,
610
+ symbols: store.recs("symbols"),
611
+ components: store.recs("components"),
612
+ decisionCorpus: store.recs("decisions"),
613
+ historical: !!asOf,
614
+ }));
609
615
  });
610
616
  // -- hunch_now (the hot view: recent activity + roadmap) --------------------
611
617
  // PUBLIC store only, per dec_29eff08c69's jurisdiction rule: an assistant may
@@ -19,7 +19,7 @@ import { RESET_SQL, embedHash } from "./schema.js";
19
19
  import { selectEmbedder } from "./embedder.js";
20
20
  import { JsonStore } from "./jsonStore.js";
21
21
  import { gitCommonDir, gitWorktreeRoot, sameGitPublication } from "../extractors/git.js";
22
- import { pathMatchesGlob } from "../core/glob.js";
22
+ import { pathMatchesGlob, pathsRelated } from "../core/glob.js";
23
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";
@@ -819,14 +819,14 @@ export class HunchStore {
819
819
  const symbols = this.recs("symbols");
820
820
  const components = this.recs("components");
821
821
  const asOf = opts.asOf;
822
- // pathRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
822
+ // pathsRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
823
823
  // so an unanchored suffix pulled unrelated files' records into why()/the
824
824
  // pre-edit grounding block (issue #32). Segment-anchored matching only.
825
- const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || pathRelated(s.file, target));
825
+ const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || pathsRelated(s.file, target));
826
826
  const symIds = new Set(matchedSymbols.map((s) => s.id));
827
827
  const fileSet = new Set(matchedSymbols.map((s) => s.file));
828
828
  const isPath = target.includes("/") || target.includes(".");
829
- const fileMatch = (files) => files.some((f) => f === target || (isPath && pathRelated(f, target)) || fileSet.has(f));
829
+ const fileMatch = (files) => files.some((f) => f === target || (isPath && pathsRelated(f, target)) || fileSet.has(f));
830
830
  return {
831
831
  target,
832
832
  decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
@@ -1057,7 +1057,7 @@ export class HunchStore {
1057
1057
  const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
1058
1058
  return this.recs("findings")
1059
1059
  .filter(live)
1060
- .filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || pathRelated(toPosixTarget(af), t))
1060
+ .filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || pathsRelated(toPosixTarget(af), t))
1061
1061
  || f.affected_symbols.some((s) => s === scope))
1062
1062
  .sort((a, b) => (SEV_FINDING[b.severity] ?? 0) - (SEV_FINDING[a.severity] ?? 0) || a.id.localeCompare(b.id));
1063
1063
  }
@@ -1294,7 +1294,7 @@ export class HunchStore {
1294
1294
  const addedDeps = new Set(added.deps);
1295
1295
  if (!addedSyms.size && !addedDeps.size)
1296
1296
  return [];
1297
- const fileRelevant = (related) => related.some((f) => files.some((x) => pathRelated(x, f)));
1297
+ const fileRelevant = (related) => related.some((f) => files.some((x) => pathsRelated(x, f)));
1298
1298
  const decisions = this.recs("decisions");
1299
1299
  // decisions tied to an active blocking constraint via source_decision
1300
1300
  const blockingDec = new Set(this.recs("constraints")
@@ -1416,7 +1416,7 @@ export class HunchStore {
1416
1416
  continue;
1417
1417
  if (!d.retired.symbols.length && !d.retired.deps.length)
1418
1418
  continue;
1419
- if (!d.related_files.some((f) => pathRelated(f, file)))
1419
+ if (!d.related_files.some((f) => pathsRelated(f, file)))
1420
1420
  continue;
1421
1421
  out.push({ decision: d.id, title: d.title, symbols: d.retired.symbols, deps: d.retired.deps });
1422
1422
  }
@@ -1558,9 +1558,6 @@ const SEV_FINDING = { critical: 4, high: 3, medium: 2, low: 1 };
1558
1558
  /** Do two repo paths refer to the same file? Exact match, or one is a trailing
1559
1559
  * path-SEGMENT suffix of the other (e.g. "x.ts" vs "src/x.ts") — anchored at a
1560
1560
  * "/" boundary so "re.ts" never matches "store.ts" (the bare-endsWith hazard). */
1561
- function pathRelated(a, b) {
1562
- return a === b || a.endsWith("/" + b) || b.endsWith("/" + a);
1563
- }
1564
1561
  function inWindow(valid_from, valid_to, asOf) {
1565
1562
  if (!asOf)
1566
1563
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.12.2",
3
+ "version": "1.13.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://hunch-pi.vercel.app",
10
- "version": "1.12.2",
10
+ "version": "1.13.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.12.2",
16
+ "version": "1.13.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {