@davesheffer/hunch 1.12.1 → 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
@@ -29,6 +29,16 @@ export function computeDrift(store, root) {
29
29
  // anchor-stale. Keeps the doc≠graph gate's false-positive rate ~zero: a routine
30
30
  // narrowing supersession (successor lists fewer files) never flags files still governed.
31
31
  const liveFiles = new Set(decisions.filter(isLive).flatMap((d) => (d.related_files ?? []).map(toPosixTarget)));
32
+ // A related_files entry may name a DIRECTORY ("vscode-extension/"), which governs every
33
+ // file beneath it. Exact Set.has cannot see that, so a live directory-scoped decision
34
+ // failed to suppress anchor-stale for files it plainly covers — a false positive that
35
+ // only appeared on a public-only store, because an overlay decision happened to claim
36
+ // the same file by exact path and masked it locally.
37
+ const liveDirs = [...liveFiles].filter((f) => f.endsWith("/"));
38
+ const governedByLiveDecision = (file) => {
39
+ const p = toPosixTarget(file);
40
+ return liveFiles.has(p) || liveDirs.some((dir) => p.startsWith(dir));
41
+ };
32
42
  const premiseEnv = { now: new Date().toISOString(), exists: (p) => existsSync(join(root, p)) };
33
43
  for (const d of decisions) {
34
44
  // 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
@@ -66,7 +76,7 @@ export function computeDrift(store, root) {
66
76
  const current = currentForTopic(decisions, d.topic);
67
77
  if (current && current.id !== d.id) {
68
78
  for (const f of d.related_files ?? []) {
69
- if (!f || f.includes("*") || liveFiles.has(toPosixTarget(f)))
79
+ if (!f || f.includes("*") || governedByLiveDecision(f))
70
80
  continue;
71
81
  if (!referenceExists(store, root, d.id, f))
72
82
  continue; // missing file is history → dead-ref's job