@davesheffer/hunch 1.20.0-rc.5 → 1.20.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/README.md CHANGED
@@ -97,6 +97,8 @@ Most memory work happens automatically after commits. These commands cover the c
97
97
  | --- | --- |
98
98
  | `hunch why <file>` | Explain why a file is built this way and what could be affected by changing it |
99
99
  | `hunch query "<question>"` | Search project memory |
100
+ | `hunch context "<task>" --profile reviewer` | Get a bounded builder, reviewer, or architect view without changing enforcement |
101
+ | `hunch change-id <base> [head]` | Bind a branch and its exact squash merge to the same content-based change ID |
100
102
  | `hunch check --working` | Check current changes against the decisions and rules your team trusts |
101
103
  | `hunch log` | See what Hunch remembered and undo a memory change if needed |
102
104
  | `hunch escalations` | See the rare questions that need a human answer |
@@ -169,7 +171,7 @@ repository, separate from the code repository. Hunch does not host it. Create a
169
171
  that every teammate can access, install Hunch on team machines and CI, then have one maintainer run:
170
172
 
171
173
  ```bash
172
- npm i -g @davesheffer/hunch@1.19.0
174
+ npm i -g @davesheffer/hunch@1.20.0
173
175
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
174
176
  git add .gitignore .hunch/team.json
175
177
  git commit -m "chore: connect shared Hunch memory"
@@ -184,7 +186,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
184
186
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
185
187
 
186
188
  ```bash
187
- npm i -g @davesheffer/hunch@1.19.0
189
+ npm i -g @davesheffer/hunch@1.20.0
188
190
  git pull
189
191
  hunch init
190
192
  hunch doctor
@@ -232,7 +234,7 @@ but stops automatic memory commits and pushes. As a team-coordinated rollback, r
232
234
  commit to stop discovery after teammates pull the revert. Existing machines retain their ignored
233
235
  local overlay until they are deliberately disconnected; do not delete the memory repo as part of a
234
236
  rollback. For this rollout, reinstall the previous published package with
235
- `npm i -g @davesheffer/hunch@1.16.0`; the release receipt resolves and records the verified rollback
237
+ `npm i -g @davesheffer/hunch@1.19.0`; the release receipt resolves and records the verified rollback
236
238
  target from the npm registry instead of trusting Git tags. Pause enforcement first as shown above,
237
239
  and keep every team client on the same release before resuming Matrix policy workflows.
238
240
 
package/dist/cli/index.js CHANGED
@@ -55,7 +55,8 @@ import { formatContext, formatStructure } from "../core/format.js";
55
55
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
56
56
  import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
57
57
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
58
- import { buildDeliveryEnvelope } from "../core/delivery.js";
58
+ import { buildDeliveryEnvelope, DELIVERY_PROFILES } from "../core/delivery.js";
59
+ import { deriveChangeIdentity } from "../core/changeIdentity.js";
59
60
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
60
61
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
61
62
  import { isHumanConfirmed } from "../core/strictgate.js";
@@ -3819,14 +3820,46 @@ program
3819
3820
  fail(`could not compile the verified evidence map: ${error.message}`);
3820
3821
  }
3821
3822
  });
3823
+ // ---- change-id (squash-stable exact delta identity) -----------------------
3824
+ program
3825
+ .command("change-id")
3826
+ .description("Derive an exact change identity that survives commit-message and squash metadata changes.")
3827
+ .argument("<base>", "base commit or ref")
3828
+ .argument("[head]", "head commit or ref", "HEAD")
3829
+ .option("--json", "emit the complete sealed identity as JSON")
3830
+ .action((base, head, opts) => {
3831
+ try {
3832
+ const identity = deriveChangeIdentity(findRoot(), base, head);
3833
+ if (opts.json) {
3834
+ console.log(JSON.stringify(identity, null, 2));
3835
+ }
3836
+ else {
3837
+ console.log([
3838
+ `${identity.change_id} (${identity.algorithm})`,
3839
+ ` exact revisions: ${identity.base_revision}..${identity.head_revision}`,
3840
+ ` exact delta: ${identity.delta_hash}`,
3841
+ ` Git stable patch ID: ${identity.patch_id ?? "unavailable (exact Hunch identity still valid)"}`,
3842
+ ` files: ${identity.file_count} (${identity.paths_hash})`,
3843
+ ` sealed receipt: ${identity.content_hash}`,
3844
+ ].join("\n"));
3845
+ }
3846
+ }
3847
+ catch (error) {
3848
+ fail(error.message);
3849
+ }
3850
+ });
3822
3851
  // ---- context (surgical retrieval) -----------------------------------------
3823
3852
  program
3824
3853
  .command("context")
3825
3854
  .description("Assemble the minimal relevant Hunch slice for a task on a file/symbol.")
3826
3855
  .argument("<target>", "file path or symbol")
3827
3856
  .option("--budget <n>", "rough token budget", "1500")
3857
+ .option("--profile <profile>", "delivery role: builder, reviewer, or architect", "builder")
3828
3858
  .option("--as-of <ref>", "time-travel: assemble the slice as it stood at a commit/tag/branch")
3829
3859
  .action(async (target, opts) => {
3860
+ if (!DELIVERY_PROFILES.includes(opts.profile)) {
3861
+ return fail(`--profile must be one of: ${DELIVERY_PROFILES.join(", ")}`);
3862
+ }
3830
3863
  const { store, root } = storeFor();
3831
3864
  const asOf = opts.asOf ? asOfDate(opts.asOf, root) : undefined;
3832
3865
  if (opts.asOf && !asOf)
@@ -3861,6 +3894,7 @@ program
3861
3894
  components: store.recs("components"),
3862
3895
  decisionCorpus: store.recs("decisions"),
3863
3896
  historical: !!asOf,
3897
+ profile: opts.profile,
3864
3898
  }));
3865
3899
  store.close();
3866
3900
  });
@@ -4464,6 +4498,7 @@ program
4464
4498
  if (!hasContent)
4465
4499
  return; // no noise on files Hunch hasn't learned yet
4466
4500
  const envelope = buildDeliveryEnvelope(ctx, {
4501
+ profile: "builder",
4467
4502
  root,
4468
4503
  symbols: store.recs("symbols"),
4469
4504
  components: store.recs("components"),
@@ -4495,6 +4530,8 @@ program
4495
4530
  delivery_reason: item.delivery_reason,
4496
4531
  provenance_status: item.provenance_status,
4497
4532
  token_cost: item.token_cost,
4533
+ delivery_profile: envelope.profile,
4534
+ ranking_policy: envelope.ranking_policy,
4498
4535
  })));
4499
4536
  if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
4500
4537
  receipts("refreshed");
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Commit-metadata-independent identity for one exact repository change.
3
+ *
4
+ * The identity hashes Git's raw tree delta (modes, paths and blob object IDs),
5
+ * not a commit SHA or prose diff. A branch range and its squash commit therefore
6
+ * share one change ID when they produce the same exact tree transition. Unlike
7
+ * `git patch-id`, whitespace-only, binary and mode changes remain significant.
8
+ */
9
+ import { execFileSync, spawnSync } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { compareCodeUnits } from "./canonicalOrder.js";
12
+ export const CHANGE_IDENTITY_SCHEMA_VERSION = "hunch.change-identity/1";
13
+ export const CHANGE_IDENTITY_ALGORITHM = "git-raw-tree-delta-sha256/1";
14
+ const GIT_OBJECT = /^[a-f0-9]{40,64}$/;
15
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
16
+ const CHANGE_ID = /^hchg_[a-f0-9]{24}$/;
17
+ const MAX_GIT_OUTPUT = 64 * 1024 * 1024;
18
+ const MAX_CHANGED_FILES = 16_384;
19
+ function canonical(value) {
20
+ if (Array.isArray(value))
21
+ return `[${value.map(canonical).join(",")}]`;
22
+ if (value && typeof value === "object") {
23
+ return `{${Object.entries(value)
24
+ .filter(([, child]) => child !== undefined)
25
+ .sort(([left], [right]) => compareCodeUnits(left, right))
26
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
27
+ .join(",")}}`;
28
+ }
29
+ return JSON.stringify(value) ?? "null";
30
+ }
31
+ function sha256(value) {
32
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
33
+ }
34
+ function gitEnvironment() {
35
+ const environment = { ...process.env, GIT_NO_REPLACE_OBJECTS: "1", LC_ALL: "C", LANG: "C" };
36
+ for (const name of [
37
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
38
+ "GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
39
+ "GIT_INDEX_FILE", "GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_INTERNAL_SUPER_PREFIX",
40
+ "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
41
+ ])
42
+ delete environment[name];
43
+ for (const name of Object.keys(environment)) {
44
+ if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(name))
45
+ delete environment[name];
46
+ }
47
+ return environment;
48
+ }
49
+ function gitText(root, args, input) {
50
+ try {
51
+ return execFileSync("git", ["-C", root, ...args], {
52
+ encoding: "utf8",
53
+ env: gitEnvironment(),
54
+ input,
55
+ maxBuffer: MAX_GIT_OUTPUT,
56
+ stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
57
+ timeout: 15_000,
58
+ }).trim();
59
+ }
60
+ catch (error) {
61
+ const stderr = error.stderr?.toString("utf8").trim().replace(/[\r\n]+/g, " ");
62
+ throw new Error(`could not derive exact Git change identity${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
63
+ }
64
+ }
65
+ function gitBytes(root, args) {
66
+ try {
67
+ return execFileSync("git", ["-C", root, ...args], {
68
+ encoding: "buffer",
69
+ env: gitEnvironment(),
70
+ maxBuffer: MAX_GIT_OUTPUT,
71
+ stdio: ["ignore", "pipe", "pipe"],
72
+ timeout: 15_000,
73
+ });
74
+ }
75
+ catch (error) {
76
+ const stderr = error.stderr?.toString("utf8").trim().replace(/[\r\n]+/g, " ");
77
+ throw new Error(`could not derive exact Git change identity${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
78
+ }
79
+ }
80
+ function exactCommit(root, ref) {
81
+ if (!ref.trim() || /[\0\r\n]/.test(ref) || ref.length > 1_024)
82
+ throw new Error("Git revision is invalid");
83
+ const revision = gitText(root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
84
+ if (!GIT_OBJECT.test(revision))
85
+ throw new Error("Git did not return an exact commit object");
86
+ return revision;
87
+ }
88
+ function patchId(root, base, head) {
89
+ const diff = gitBytes(root, [
90
+ "diff", "--binary", "--full-index", "--no-renames", "--no-ext-diff", "--no-textconv",
91
+ base, head, "--",
92
+ ]);
93
+ const result = spawnSync("git", ["patch-id", "--stable"], {
94
+ cwd: root,
95
+ env: gitEnvironment(),
96
+ input: diff,
97
+ encoding: "utf8",
98
+ maxBuffer: MAX_GIT_OUTPUT,
99
+ timeout: 15_000,
100
+ stdio: ["pipe", "pipe", "pipe"],
101
+ });
102
+ if (result.error || result.status !== 0)
103
+ throw new Error("could not derive interoperable Git patch ID");
104
+ const value = result.stdout.trim().split(/\s+/)[0] ?? "";
105
+ if (!value)
106
+ return null;
107
+ if (!GIT_OBJECT.test(value))
108
+ throw new Error("Git returned an invalid patch ID");
109
+ return value;
110
+ }
111
+ /** Derive one deterministic receipt from two commit-ish references. */
112
+ export function deriveChangeIdentity(root, baseRef, headRef = "HEAD") {
113
+ const baseRevision = exactCommit(root, baseRef);
114
+ const headRevision = exactCommit(root, headRef);
115
+ const baseTree = gitText(root, ["rev-parse", "--verify", `${baseRevision}^{tree}`]);
116
+ const headTree = gitText(root, ["rev-parse", "--verify", `${headRevision}^{tree}`]);
117
+ if (!GIT_OBJECT.test(baseTree) || !GIT_OBJECT.test(headTree))
118
+ throw new Error("Git tree identity is invalid");
119
+ const rawDelta = gitBytes(root, [
120
+ "diff-tree", "--no-commit-id", "--raw", "--full-index", "-r", "-z", "--no-renames",
121
+ baseRevision, headRevision, "--",
122
+ ]);
123
+ if (rawDelta.byteLength === 0)
124
+ throw new Error("exact Git change is empty");
125
+ const rawPaths = gitBytes(root, ["diff", "--name-only", "-z", "--no-renames", baseRevision, headRevision, "--"]);
126
+ const pathLengths = [];
127
+ let start = 0;
128
+ for (let index = 0; index < rawPaths.length; index++) {
129
+ if (rawPaths[index] !== 0)
130
+ continue;
131
+ pathLengths.push(index - start);
132
+ start = index + 1;
133
+ }
134
+ if (!rawPaths.length || rawPaths[rawPaths.length - 1] !== 0 || start !== rawPaths.length
135
+ || !pathLengths.length || pathLengths.length > MAX_CHANGED_FILES
136
+ || pathLengths.some((length) => length < 1 || length > 4_096)) {
137
+ throw new Error("exact Git change has an invalid or unbounded file set");
138
+ }
139
+ const deltaHash = sha256(rawDelta);
140
+ const changeSeed = canonical({ algorithm: CHANGE_IDENTITY_ALGORITHM, delta_hash: deltaHash });
141
+ const changeId = `hchg_${sha256(changeSeed).slice("sha256:".length, "sha256:".length + 24)}`;
142
+ const unsigned = {
143
+ schema: CHANGE_IDENTITY_SCHEMA_VERSION,
144
+ algorithm: CHANGE_IDENTITY_ALGORITHM,
145
+ change_id: changeId,
146
+ base_revision: baseRevision,
147
+ head_revision: headRevision,
148
+ base_tree: baseTree,
149
+ head_tree: headTree,
150
+ delta_hash: deltaHash,
151
+ patch_id: patchId(root, baseRevision, headRevision),
152
+ file_count: pathLengths.length,
153
+ paths_hash: sha256(rawPaths),
154
+ };
155
+ const identity = { ...unsigned, content_hash: sha256(canonical(unsigned)) };
156
+ assertChangeIdentity(identity);
157
+ return identity;
158
+ }
159
+ /** Validate an identity without trusting any caller-supplied seal. */
160
+ export function assertChangeIdentity(value) {
161
+ if (!value || typeof value !== "object" || Array.isArray(value))
162
+ throw new Error("change identity is invalid");
163
+ const identity = value;
164
+ const expectedFields = [
165
+ "schema", "algorithm", "change_id", "base_revision", "head_revision", "base_tree", "head_tree",
166
+ "delta_hash", "patch_id", "file_count", "paths_hash", "content_hash",
167
+ ];
168
+ if (Object.keys(value).sort(compareCodeUnits).join("\0") !== expectedFields.sort(compareCodeUnits).join("\0")
169
+ || identity.schema !== CHANGE_IDENTITY_SCHEMA_VERSION || identity.algorithm !== CHANGE_IDENTITY_ALGORITHM
170
+ || !CHANGE_ID.test(identity.change_id) || !GIT_OBJECT.test(identity.base_revision)
171
+ || !GIT_OBJECT.test(identity.head_revision) || !GIT_OBJECT.test(identity.base_tree)
172
+ || !GIT_OBJECT.test(identity.head_tree) || !SHA256.test(identity.delta_hash)
173
+ || (identity.patch_id !== null && !GIT_OBJECT.test(identity.patch_id))
174
+ || !Number.isSafeInteger(identity.file_count) || identity.file_count < 1 || identity.file_count > MAX_CHANGED_FILES
175
+ || !SHA256.test(identity.paths_hash) || !SHA256.test(identity.content_hash)) {
176
+ throw new Error("change identity fields are invalid");
177
+ }
178
+ const expectedChange = `hchg_${sha256(canonical({ algorithm: identity.algorithm, delta_hash: identity.delta_hash }))
179
+ .slice("sha256:".length, "sha256:".length + 24)}`;
180
+ const { content_hash: _contentHash, ...unsigned } = identity;
181
+ if (identity.change_id !== expectedChange || identity.content_hash !== sha256(canonical(unsigned))) {
182
+ throw new Error("change identity seal is invalid");
183
+ }
184
+ }
185
+ export function changesAreEquivalent(left, right) {
186
+ assertChangeIdentity(left);
187
+ assertChangeIdentity(right);
188
+ return left.algorithm === right.algorithm
189
+ && left.change_id === right.change_id
190
+ && left.delta_hash === right.delta_hash;
191
+ }
192
+ //# sourceMappingURL=changeIdentity.js.map
@@ -14,10 +14,39 @@ import { toPosixTarget } from "./paths.js";
14
14
  import { renderGrounding } from "./topics.js";
15
15
  import { LANDSCAPE_FRAGMENT_SCHEMA_VERSION, assertLandscapeDeliveryFragment, createLandscapeDeliveryFragment, landscapeFragmentHash, } from "./landscapeDelivery.js";
16
16
  export const DELIVERY_ENVELOPE_SCHEMA_VERSION = "hunch.delivery-envelope/1";
17
+ export const DELIVERY_PROFILE_POLICY_VERSION = "hunch.delivery-profile/1";
18
+ export const DELIVERY_PROFILES = ["builder", "reviewer", "architect"];
17
19
  const SEVERITY = { advisory: 1, warning: 2, blocking: 3, low: 1, medium: 2, high: 3, critical: 4 };
18
20
  const MIN_ADVISORY_CONFIDENCE = 0.5;
19
21
  const MIN_UNCONDITIONED_CONFIDENCE = 0.7;
20
22
  const MAX_ACTIONABLE_HYPOTHESES = 2;
23
+ const MAX_PROFILE_HEADLINES = 8;
24
+ const PROFILE_BASE_SCORE = {
25
+ builder: {
26
+ constraints: 900,
27
+ decisions: 800,
28
+ bugs: 750,
29
+ findings: 650,
30
+ resources: 550,
31
+ relationships: 525,
32
+ },
33
+ reviewer: {
34
+ constraints: 800,
35
+ decisions: 750,
36
+ bugs: 900,
37
+ findings: 850,
38
+ resources: 550,
39
+ relationships: 525,
40
+ },
41
+ architect: {
42
+ constraints: 800,
43
+ decisions: 900,
44
+ bugs: 650,
45
+ findings: 600,
46
+ resources: 850,
47
+ relationships: 825,
48
+ },
49
+ };
21
50
  const TASK_STOP_WORDS = new Set([
22
51
  "a", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "does", "for", "from",
23
52
  "has", "have", "in", "into", "is", "it", "its", "of", "on", "or", "that", "the", "this", "to",
@@ -379,6 +408,10 @@ export function assertDeliveryEnvelope(envelope) {
379
408
  }
380
409
  if (!/^hdr_[a-f0-9]{24}$/.test(envelope.receipt_id))
381
410
  throw new Error("delivery envelope receipt id is invalid");
411
+ if (!DELIVERY_PROFILES.includes(envelope.profile)
412
+ || envelope.ranking_policy !== DELIVERY_PROFILE_POLICY_VERSION) {
413
+ throw new Error("delivery envelope profile policy is unsupported");
414
+ }
382
415
  if (!Number.isSafeInteger(envelope.budget_tokens) || envelope.budget_tokens < 0
383
416
  || !Number.isSafeInteger(envelope.used_chars) || envelope.used_chars !== charCount(envelope.text)
384
417
  || !Number.isSafeInteger(envelope.accounted_chars) || envelope.accounted_chars < envelope.used_chars) {
@@ -426,6 +459,7 @@ export function assertDeliveryEnvelope(envelope) {
426
459
  }
427
460
  /** Build the one envelope used by CLI, MCP, and the edit hook. */
428
461
  export function buildDeliveryEnvelope(ctx, options = {}) {
462
+ const profile = options.profile ?? "builder";
429
463
  const budget = Number.isFinite(ctx.budget_tokens) ? Math.max(0, Math.floor(ctx.budget_tokens)) : 1500;
430
464
  const cap = budget * 4;
431
465
  const reachability = options.commitReachability
@@ -439,7 +473,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
439
473
  candidates.push({
440
474
  ref: { kind: "constraints", record_id: constraint.id },
441
475
  mandatory: !retired && constraint.severity === "blocking",
442
- score: 900 + SEVERITY[constraint.severity] * 10 + (constraint.provenance.confidence ?? 0),
476
+ score: PROFILE_BASE_SCORE[profile].constraints + SEVERITY[constraint.severity] * 10 + (constraint.provenance.confidence ?? 0),
443
477
  provenance: validation.state,
444
478
  staleDetail: validation.detail,
445
479
  retiredDetail: retired ? "constraint is retired at HEAD" : undefined,
@@ -459,7 +493,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
459
493
  candidates.push({
460
494
  ref: { kind: "decisions", record_id: decision.id },
461
495
  mandatory: false,
462
- score: 700 + (decision.status === "accepted" ? 20 : 0) + (decision.provenance.confidence ?? 0),
496
+ score: PROFILE_BASE_SCORE[profile].decisions + (decision.status === "accepted" ? 20 : 0) + (decision.provenance.confidence ?? 0),
463
497
  provenance: validation.state,
464
498
  staleDetail: validation.detail,
465
499
  retiredDetail: retired ? `decision is ${decision.status} at HEAD` : undefined,
@@ -480,7 +514,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
480
514
  candidates.push({
481
515
  ref: { kind: "bugs", record_id: bug.id },
482
516
  mandatory: false,
483
- score: 800 + SEVERITY[bug.severity] * 10 + (bug.status === "open" || bug.status === "regressed" ? 10 : 0),
517
+ score: PROFILE_BASE_SCORE[profile].bugs + SEVERITY[bug.severity] * 10 + (bug.status === "open" || bug.status === "regressed" ? 10 : 0),
484
518
  provenance: validation.state,
485
519
  staleDetail: validation.detail,
486
520
  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}")`,
@@ -495,7 +529,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
495
529
  candidates.push({
496
530
  ref: { kind: "findings", record_id: finding.id },
497
531
  mandatory: false,
498
- score: 600 + SEVERITY[finding.severity] * 10,
532
+ score: PROFILE_BASE_SCORE[profile].findings + SEVERITY[finding.severity] * 10,
499
533
  provenance: validation.state,
500
534
  staleDetail: validation.detail,
501
535
  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}")`,
@@ -509,7 +543,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
509
543
  candidates.push({
510
544
  ref: { kind: "resources", record_id: record.id },
511
545
  mandatory: false,
512
- score: 550 - item.selectionRank,
546
+ score: PROFILE_BASE_SCORE[profile].resources - item.selectionRank,
513
547
  provenance: "current",
514
548
  line,
515
549
  landscapeResource: item,
@@ -527,7 +561,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
527
561
  candidates.push({
528
562
  ref: { kind: "relationships", record_id: record.id },
529
563
  mandatory: false,
530
- score: 525 - item.selectionRank,
564
+ score: PROFILE_BASE_SCORE[profile].relationships - item.selectionRank,
531
565
  provenance: "current",
532
566
  line,
533
567
  landscapeRelationship: item,
@@ -548,6 +582,8 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
548
582
  const text = fitText(empty, cap);
549
583
  return finalizeDeliveryEnvelope({
550
584
  schema_version: DELIVERY_ENVELOPE_SCHEMA_VERSION,
585
+ profile,
586
+ ranking_policy: DELIVERY_PROFILE_POLICY_VERSION,
551
587
  text,
552
588
  delivered: [],
553
589
  hypotheses: [],
@@ -617,14 +653,30 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
617
653
  }
618
654
  boundedEligible.push(candidate);
619
655
  }
620
- const recordCandidates = boundedEligible.filter((candidate) => candidate.ref);
621
- const structuralCandidates = boundedEligible.filter((candidate) => !candidate.ref);
656
+ const profileBoundedEligible = [];
657
+ let nonBlockingHeadlines = 0;
658
+ for (const candidate of boundedEligible) {
659
+ if (candidate.ref && !candidate.mandatory) {
660
+ if (nonBlockingHeadlines >= MAX_PROFILE_HEADLINES) {
661
+ omitted.push({
662
+ ...candidate.ref,
663
+ reason: "profile-cap",
664
+ detail: `${profile} delivery is capped at ${MAX_PROFILE_HEADLINES} non-blocking headlines; use hunch_why or a narrower task to expand this record`,
665
+ });
666
+ continue;
667
+ }
668
+ nonBlockingHeadlines++;
669
+ }
670
+ profileBoundedEligible.push(candidate);
671
+ }
672
+ const recordCandidates = profileBoundedEligible.filter((candidate) => candidate.ref);
673
+ const structuralCandidates = profileBoundedEligible.filter((candidate) => !candidate.ref);
622
674
  const lines = [
623
675
  `# Hunch context for "${ctx.target}"`,
624
676
  "",
625
677
  query.taskPhrase
626
- ? `## 🧠 Bounded memory (Invariants · max ${MAX_ACTIONABLE_HYPOTHESES} decision hypotheses · Bugs · Known findings)`
627
- : "## 🧠 Ranked memory (Invariants · Decisions · Bugs · Known findings)",
678
+ ? `## 🧠 ${profile === "builder" ? "Bounded" : `${profile[0].toUpperCase()}${profile.slice(1)}-bounded`} memory (Invariants · max ${MAX_ACTIONABLE_HYPOTHESES} decision hypotheses · Bugs · Known findings)`
679
+ : `## 🧠 ${profile === "builder" ? "Ranked" : `${profile[0].toUpperCase()}${profile.slice(1)}-ranked`} memory (Invariants · Decisions · Bugs · Known findings)`,
628
680
  ];
629
681
  if (candidates.some((candidate) => candidate.abstainReason)) {
630
682
  lines.push("Evidence rule: explicit task, repro, and test evidence outranks advisory memory; weak unverified matches are withheld.");
@@ -720,6 +772,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
720
772
  const staleCount = omitted.filter((item) => item.reason === "stale-provenance" || item.reason === "retired").length;
721
773
  const budgetCount = omitted.filter((item) => item.reason === "budget").length;
722
774
  const actionabilityCount = omitted.filter((item) => item.reason === "actionability-cap").length;
775
+ const profileCapCount = omitted.filter((item) => item.reason === "profile-cap").length;
723
776
  const abstention = emptyAbstention();
724
777
  for (const item of omitted) {
725
778
  if (item.reason === "low-confidence" || item.reason === "insufficient-context" || item.reason === "low-relevance") {
@@ -735,6 +788,7 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
735
788
  staleCount ? `${staleCount} stale/retired record(s) withheld; run hunch drift or hunch_why(id) to inspect.` : "",
736
789
  budgetCount ? `${budgetCount} lower-ranked record(s) omitted by budget; use hunch_why(id) to drill down.` : "",
737
790
  actionabilityCount ? `${actionabilityCount} additional decision hypothesis/hypotheses withheld by the actionability cap; refine the task evidence or use hunch_why(id).` : "",
791
+ profileCapCount ? `${profileCapCount} non-blocking record(s) withheld by the ${profile} profile cap; use hunch_why(id) or narrow the task.` : "",
738
792
  abstention.active ? `${abstention.withheld} weak prescriptive record(s) withheld by confidence/relevance abstention. ${abstention.retry_hint}` : "",
739
793
  ].filter(Boolean);
740
794
  if (notes.length) {
@@ -785,12 +839,12 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
785
839
  const landscapeOmissions = omitted
786
840
  .filter((item) => item.kind === "resources" || item.kind === "relationships")
787
841
  .flatMap((item) => {
788
- if (!["budget", "stale-provenance", "endpoint-not-delivered", "landscape-cap"].includes(item.reason))
842
+ if (!["budget", "stale-provenance", "endpoint-not-delivered", "landscape-cap", "profile-cap"].includes(item.reason))
789
843
  return [];
790
844
  return [{
791
845
  kind: item.kind,
792
846
  recordId: item.record_id,
793
- reason: item.reason,
847
+ reason: (item.reason === "profile-cap" ? "landscape-cap" : item.reason),
794
848
  detail: item.detail,
795
849
  }];
796
850
  });
@@ -806,6 +860,8 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
806
860
  : null;
807
861
  return finalizeDeliveryEnvelope({
808
862
  schema_version: DELIVERY_ENVELOPE_SCHEMA_VERSION,
863
+ profile,
864
+ ranking_policy: DELIVERY_PROFILE_POLICY_VERSION,
809
865
  text,
810
866
  delivered,
811
867
  hypotheses,
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { findingId } from "./ids.js";
4
4
  import { FindingSchema, isCredentialFreeText } from "./types.js";
5
+ import { assertChangeIdentity, CHANGE_IDENTITY_ALGORITHM, CHANGE_IDENTITY_SCHEMA_VERSION, } from "./changeIdentity.js";
5
6
  export const USEFULNESS_OBSERVATION_SCHEMA_VERSION = "hunch.usefulness-observation/1";
6
7
  export const USEFULNESS_SIGNALS = [
7
8
  "used",
@@ -22,6 +23,27 @@ const EvidenceReferenceSchema = z.object({
22
23
  ref: z.string().min(1).max(512),
23
24
  hash: z.string().regex(SHA256),
24
25
  }).strict();
26
+ const OutcomeChangeIdentitySchema = z.object({
27
+ schema: z.literal(CHANGE_IDENTITY_SCHEMA_VERSION),
28
+ algorithm: z.literal(CHANGE_IDENTITY_ALGORITHM),
29
+ change_id: z.string().regex(/^hchg_[a-f0-9]{24}$/),
30
+ base_revision: z.string().regex(GIT_OBJECT),
31
+ head_revision: z.string().regex(GIT_OBJECT),
32
+ base_tree: z.string().regex(GIT_OBJECT),
33
+ head_tree: z.string().regex(GIT_OBJECT),
34
+ delta_hash: z.string().regex(SHA256),
35
+ patch_id: z.string().regex(GIT_OBJECT).nullable(),
36
+ file_count: z.number().int().positive().max(16_384),
37
+ paths_hash: z.string().regex(SHA256),
38
+ content_hash: z.string().regex(SHA256),
39
+ }).strict().superRefine((identity, ctx) => {
40
+ try {
41
+ assertChangeIdentity(identity);
42
+ }
43
+ catch (error) {
44
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: error.message });
45
+ }
46
+ });
25
47
  export const UsefulnessObservationSchema = z.object({
26
48
  schema: z.literal(USEFULNESS_OBSERVATION_SCHEMA_VERSION),
27
49
  observationId: z.string().regex(OBSERVATION_ID),
@@ -46,6 +68,8 @@ export const UsefulnessObservationSchema = z.object({
46
68
  recordRevision: z.string().regex(SHA256),
47
69
  contentHash: z.string().regex(SHA256),
48
70
  }).strict(),
71
+ /** Optional until hosts can produce it; when present it survives squash metadata. */
72
+ change: OutcomeChangeIdentitySchema.optional(),
49
73
  signal: z.enum(USEFULNESS_SIGNALS),
50
74
  evidence: z.array(EvidenceReferenceSchema).max(64),
51
75
  observedAt: z.string().min(1).max(64),
@@ -146,6 +170,7 @@ function usefulnessObservationUnsigned(observation) {
146
170
  episode: { ...observation.episode },
147
171
  delivery: { ...observation.delivery },
148
172
  record: { ...observation.record },
173
+ ...(observation.change ? { change: { ...observation.change } } : {}),
149
174
  signal: observation.signal,
150
175
  evidence: observation.evidence.map((item) => ({ ...item })),
151
176
  observedAt: observation.observedAt,
@@ -160,6 +185,7 @@ export function createUsefulnessObservation(input) {
160
185
  episode: { ...input.episode },
161
186
  delivery: { ...input.delivery },
162
187
  record: { ...input.record },
188
+ ...(input.change ? { change: { ...input.change } } : {}),
163
189
  signal: input.signal,
164
190
  evidence: input.evidence.map((item) => ({ ...item })),
165
191
  observedAt: input.observedAt,
@@ -194,6 +220,7 @@ export function usefulnessObservationFinding(value) {
194
220
  `episode:${observation.episode.episodeId}@${observation.episode.episodeHash}`,
195
221
  `delivery:${observation.delivery.receiptRef}@${observation.delivery.receiptHash}`,
196
222
  `record:${observation.record.recordId}@${observation.record.recordRevision}`,
223
+ ...(observation.change ? [`change:${observation.change.change_id}@${observation.change.content_hash}`] : []),
197
224
  ...observation.evidence.map((item) => `${item.kind}:${item.ref}@${item.hash}`),
198
225
  ];
199
226
  return FindingSchema.parse({
@@ -41,6 +41,8 @@ const RECEIPT_COLUMNS = [
41
41
  ["delivery_reason", "TEXT"],
42
42
  ["provenance_status", "TEXT"],
43
43
  ["token_cost", "INTEGER"],
44
+ ["delivery_profile", "TEXT"],
45
+ ["ranking_policy", "TEXT"],
44
46
  ];
45
47
  function columnNames(db) {
46
48
  const rows = db.prepare("PRAGMA table_info(served)").all();
@@ -89,9 +91,9 @@ export function recordServed(root, entries) {
89
91
  const db = openServedDb(root);
90
92
  try {
91
93
  const at = new Date().toISOString();
92
- const insert = db.prepare("INSERT INTO served (at, session, event, kind, record_id, target, rank, delivery_reason, provenance_status, token_cost) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
94
+ const insert = db.prepare("INSERT INTO served (at, session, event, kind, record_id, target, rank, delivery_reason, provenance_status, token_cost, delivery_profile, ranking_policy) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
93
95
  for (const entry of entries) {
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);
96
+ 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, entry.delivery_profile ?? null, entry.ranking_policy ?? null);
95
97
  }
96
98
  }
97
99
  finally {
@@ -117,7 +119,7 @@ export function servedSummary(root) {
117
119
  ROUND(AVG(token_cost), 2) AS average_token_cost
118
120
  FROM served GROUP BY record_id, kind ORDER BY serves DESC, refreshes DESC`).all();
119
121
  const rawRecent = db.prepare(`SELECT at, session AS session_id, event, kind, record_id, target,
120
- rank, delivery_reason, provenance_status, token_cost
122
+ rank, delivery_reason, provenance_status, token_cost, delivery_profile, ranking_policy
121
123
  FROM served ORDER BY rowid DESC LIMIT 50`).all();
122
124
  const rows = rawRows.map((row) => ({ ...row }));
123
125
  const recent = rawRecent.map((receipt) => ({ ...receipt }));
@@ -46,7 +46,7 @@ export function renderHunchSection(store, root) {
46
46
  lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
47
47
  lines.push("");
48
48
  lines.push("**Orient (session/task start):**");
49
- lines.push("- `hunch_context(target_or_task)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST.**");
49
+ lines.push("- `hunch_context(target)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST.**");
50
50
  lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
51
51
  lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task, before re-deriving them.");
52
52
  lines.push("- `hunch_escalations()` — the decisions only the HUMAN can make (including one exact imported ADR at a time, topic conflicts, and policy calls). Normally empty; when it isn't, ASK the user inline — an entry is a question, silence is never approval. Apply an ADR answer only through `hunch_review_imported_adr` with its printed source and review hashes.");
@@ -25,7 +25,8 @@ import { formatStructure } from "../core/format.js";
25
25
  import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
26
26
  import { compileVerifiedEvidenceMap, EvidenceExecutionSchema, EvidenceInterventionSchema, EvidenceProbeSchema, formatVerifiedEvidenceMap, VerifiedEvidenceReceiptSchema, } from "../core/evidenceMap.js";
27
27
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
28
- import { buildDeliveryEnvelope } from "../core/delivery.js";
28
+ import { buildDeliveryEnvelope, DELIVERY_PROFILE_POLICY_VERSION, DELIVERY_PROFILES, } from "../core/delivery.js";
29
+ import { CHANGE_IDENTITY_ALGORITHM, CHANGE_IDENTITY_SCHEMA_VERSION, deriveChangeIdentity, } from "../core/changeIdentity.js";
29
30
  import { armExecutionObligations, loadPipelineState, savePipelineState } from "../core/pipeline.js";
30
31
  import { recordServed } from "../core/served.js";
31
32
  import { EdgeSchema, ResourceSchema } from "../core/types.js";
@@ -181,6 +182,8 @@ const LANDSCAPE_FRAGMENT_SCHEMA = z.object({
181
182
  * backward-compatible text block. */
182
183
  const DELIVERY_OUTPUT_SCHEMA = z.object({
183
184
  schema_version: z.literal("hunch.delivery-envelope/1"),
185
+ profile: z.enum(DELIVERY_PROFILES),
186
+ ranking_policy: z.literal(DELIVERY_PROFILE_POLICY_VERSION),
184
187
  receipt_id: z.string().regex(/^hdr_[a-f0-9]{24}$/),
185
188
  text: z.string(),
186
189
  delivered: z.array(z.object({
@@ -214,7 +217,7 @@ const DELIVERY_OUTPUT_SCHEMA = z.object({
214
217
  omitted: z.array(z.object({
215
218
  kind: z.enum(["constraints", "decisions", "bugs", "findings", "resources", "relationships"]),
216
219
  record_id: z.string(),
217
- reason: z.enum(["budget", "stale-provenance", "retired", "actionability-cap", "endpoint-not-delivered", "landscape-cap", "low-confidence", "insufficient-context", "low-relevance"]),
220
+ reason: z.enum(["budget", "stale-provenance", "retired", "actionability-cap", "endpoint-not-delivered", "landscape-cap", "profile-cap", "low-confidence", "insufficient-context", "low-relevance"]),
218
221
  detail: z.string(),
219
222
  })),
220
223
  landscape: LANDSCAPE_FRAGMENT_SCHEMA.nullable(),
@@ -233,6 +236,20 @@ const DELIVERY_OUTPUT_SCHEMA = z.object({
233
236
  retry_hint: z.string().nullable(),
234
237
  }),
235
238
  });
239
+ const CHANGE_IDENTITY_OUTPUT_SCHEMA = z.object({
240
+ schema: z.literal(CHANGE_IDENTITY_SCHEMA_VERSION),
241
+ algorithm: z.literal(CHANGE_IDENTITY_ALGORITHM),
242
+ change_id: z.string().regex(/^hchg_[a-f0-9]{24}$/),
243
+ base_revision: z.string().regex(/^[a-f0-9]{40,64}$/),
244
+ head_revision: z.string().regex(/^[a-f0-9]{40,64}$/),
245
+ base_tree: z.string().regex(/^[a-f0-9]{40,64}$/),
246
+ head_tree: z.string().regex(/^[a-f0-9]{40,64}$/),
247
+ delta_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
248
+ patch_id: z.string().regex(/^[a-f0-9]{40,64}$/).nullable(),
249
+ file_count: z.number().int().positive().max(16_384),
250
+ paths_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
251
+ content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
252
+ });
236
253
  /** Return the same human-readable brief older clients consume plus the exact
237
254
  * machine-readable envelope. Receipt recording is deliberately best-effort:
238
255
  * recordServed never throws, so telemetry can never cost a delivery. */
@@ -255,6 +272,8 @@ function deliveredContext(root, target, envelope, sessionId) {
255
272
  delivery_reason: item.delivery_reason,
256
273
  provenance_status: item.provenance_status,
257
274
  token_cost: item.token_cost,
275
+ delivery_profile: structuredContent.profile,
276
+ ranking_policy: structuredContent.ranking_policy,
258
277
  })));
259
278
  return {
260
279
  content: [{ type: "text", text: structuredContent.text }],
@@ -458,14 +477,22 @@ export function buildServerWithRootControl(initialRoot) {
458
477
  };
459
478
  const pullTeamMemory = (force = false) => {
460
479
  if (!store.privateDir)
461
- return;
480
+ return "not_shared";
462
481
  const now = Date.now();
463
482
  if (!force && now < nextRemotePullAt)
464
- return;
465
- notePull(pullHunchStatus(store.privateDir, {
466
- timeoutMs: 5_000,
467
- remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
468
- }), Date.now());
483
+ return "cooldown";
484
+ let status;
485
+ try {
486
+ status = pullHunchStatus(store.privateDir, {
487
+ timeoutMs: 5_000,
488
+ remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
489
+ });
490
+ }
491
+ catch {
492
+ status = "failed";
493
+ }
494
+ notePull(status, Date.now());
495
+ return status;
469
496
  };
470
497
  // A source stamp is acknowledged ONLY after a stable, successful rebuild. If
471
498
  // another process changes the atomic JSON tree during the rebuild, retry once;
@@ -607,10 +634,7 @@ export function buildServerWithRootControl(initialRoot) {
607
634
  return err("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
608
635
  }
609
636
  if (store.mode === "shared" && store.privateDir) {
610
- try {
611
- pullTeamMemory();
612
- }
613
- catch { /* offline / lock held / invalid remote — use local */ }
637
+ pullTeamMemory();
614
638
  // Recompute the full semantic + physical snapshot after the synchronous
615
639
  // network seam. A paired team.json/origin change can occur while fetch is
616
640
  // blocked; serving after that race would attach the old checkout to a new
@@ -786,6 +810,31 @@ export function buildServerWithRootControl(initialRoot) {
786
810
  }
787
811
  return ok(`Blast radius for "${target}":\n\n${parts.join("\n\n")}`);
788
812
  });
813
+ // -- hunch_change_identity (squash-stable exact delta identity) ------------
814
+ server.registerTool("hunch_change_identity", {
815
+ title: "Derive an exact squash-stable change identity",
816
+ description: "Content-address the exact Git tree delta between two revisions. Commit messages, authors and squash metadata do not affect the identity; whitespace, paths, modes and blob bytes do. Read-only and deterministic.",
817
+ inputSchema: {
818
+ base_ref: z.string().min(1).max(1_024).describe("Base commit or ref for the exact tree transition."),
819
+ head_ref: z.string().min(1).max(1_024).optional().describe("Head commit or ref (default HEAD)."),
820
+ cwd: cwdHintField,
821
+ },
822
+ outputSchema: CHANGE_IDENTITY_OUTPUT_SCHEMA,
823
+ }, async ({ base_ref, head_ref }) => {
824
+ try {
825
+ const identity = CHANGE_IDENTITY_OUTPUT_SCHEMA.parse(deriveChangeIdentity(root, base_ref, head_ref ?? "HEAD"));
826
+ return {
827
+ content: [{
828
+ type: "text",
829
+ text: `${identity.change_id} — ${identity.file_count} exact file delta(s), ${identity.delta_hash}; sealed ${identity.content_hash}`,
830
+ }],
831
+ structuredContent: identity,
832
+ };
833
+ }
834
+ catch (error) {
835
+ return err(error.message);
836
+ }
837
+ });
789
838
  // -- hunch_context (surgical retrieval) -----------------------------------
790
839
  server.registerTool("hunch_context", {
791
840
  title: "Assemble the minimal relevant Hunch slice for a task",
@@ -793,10 +842,11 @@ export function buildServerWithRootControl(initialRoot) {
793
842
  inputSchema: {
794
843
  target: z.string().describe("A file path, symbol, or task phrase you're about to work on."),
795
844
  budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
845
+ profile: z.enum(DELIVERY_PROFILES).optional().describe("Delivery role: builder (default), reviewer, or architect. Changes non-blocking order only."),
796
846
  as_of: z.string().optional().describe("Time-travel ref (commit/tag/branch): assemble the slice as it stood then."),
797
847
  },
798
848
  outputSchema: DELIVERY_OUTPUT_SCHEMA,
799
- }, async ({ target, budget_tokens, as_of }, extra) => {
849
+ }, async ({ target, budget_tokens, profile, as_of }, extra) => {
800
850
  const asOf = as_of ? asOfDate(as_of, root) : undefined;
801
851
  if (as_of && !asOf)
802
852
  return err(`Could not resolve as_of "${as_of}" to a commit.`);
@@ -807,6 +857,7 @@ export function buildServerWithRootControl(initialRoot) {
807
857
  components: store.recs("components"),
808
858
  decisionCorpus: store.recs("decisions"),
809
859
  historical: !!asOf,
860
+ profile: profile ?? "builder",
810
861
  };
811
862
  // Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
812
863
  // used to return an empty brief while the graph held the answer — fall back to
@@ -1041,11 +1092,31 @@ export function buildServerWithRootControl(initialRoot) {
1041
1092
  // -- hunch_current_decision (decision-grounding: current(topic)) ----------
1042
1093
  server.registerTool("hunch_current_decision", {
1043
1094
  title: "Current decision for a topic",
1044
- description: "Decision-grounding: return the single CURRENT (accepted, non-superseded) decision anchored to a topic — the authoritative answer a doc or diff is checked against, plus what it rejected. If a topic has NO current decision, or an unresolved collision (>1 live), it says so and injects nothing (fail-safe).",
1095
+ description: "Decision-grounding: return the single CURRENT (accepted, non-superseded) decision anchored to a topic — the authoritative answer a doc or diff is checked against, plus what it rejected. A shared-store miss is confirmed against fresh team memory before Hunch says the topic has no current decision. If freshness is unavailable, or a topic has an unresolved collision (>1 live), it injects nothing (fail-safe).",
1045
1096
  inputSchema: { topic: z.string().describe("the decision anchor, e.g. 'auth-transport'") },
1046
1097
  }, async ({ topic }) => {
1047
- const decs = store.recs("decisions");
1048
- const live = liveForTopic(decs, topic);
1098
+ let decs = store.recs("decisions");
1099
+ let live = liveForTopic(decs, topic);
1100
+ // A prior transient fetch failure may have placed ordinary tool traffic in
1101
+ // backoff. That is acceptable for cached positive reads, but an exact topic
1102
+ // miss is an authority claim: local absence must never be presented as team
1103
+ // absence. Bypass the cooldown once, then reread only after a successful
1104
+ // bounded convergence. An unavailable remote produces an explicit abstention.
1105
+ if (live.length === 0 && store.mode === "shared" && store.privateDir) {
1106
+ const status = pullTeamMemory(true);
1107
+ if (status !== "updated" && status !== "current") {
1108
+ return err(`Shared team memory refresh is ${status}; Hunch cannot confirm that topic "${topic}" has no current decision. Retry when the shared store is available.`);
1109
+ }
1110
+ try {
1111
+ if (store.sourceStamp() !== indexedSourceStamp)
1112
+ refreshIndex();
1113
+ }
1114
+ catch {
1115
+ return err(`Shared team memory refreshed, but its derived index could not be rebuilt; Hunch cannot confirm that topic "${topic}" is absent. Retry this read.`);
1116
+ }
1117
+ decs = store.recs("decisions");
1118
+ live = liveForTopic(decs, topic);
1119
+ }
1049
1120
  if (live.length === 0)
1050
1121
  return ok(`No current decision for topic "${topic}". (Un-anchored, or never captured.)`);
1051
1122
  if (live.length > 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.20.0-rc.5",
3
+ "version": "1.20.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.20.0-rc.5",
10
+ "version": "1.20.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.20.0-rc.5",
16
+ "version": "1.20.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {