@davesheffer/hunch 1.20.0-rc.4 → 1.20.0-rc.6

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 |
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,
@@ -0,0 +1,250 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { findingId } from "./ids.js";
4
+ import { FindingSchema, isCredentialFreeText } from "./types.js";
5
+ import { assertChangeIdentity, CHANGE_IDENTITY_ALGORITHM, CHANGE_IDENTITY_SCHEMA_VERSION, } from "./changeIdentity.js";
6
+ export const USEFULNESS_OBSERVATION_SCHEMA_VERSION = "hunch.usefulness-observation/1";
7
+ export const USEFULNESS_SIGNALS = [
8
+ "used",
9
+ "prevented",
10
+ "near_miss",
11
+ "contradicted",
12
+ "stale",
13
+ "unused",
14
+ "unknown",
15
+ ];
16
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
17
+ const GIT_OBJECT = /^[a-f0-9]{40,64}$/;
18
+ const RECEIPT_REF = /^hunch-memory:hmctx_[a-f0-9]{32}$/;
19
+ const OBSERVATION_ID = /^huo_[a-f0-9]{24}$/;
20
+ const MAX_RETENTION_MS = 365 * 24 * 60 * 60 * 1_000;
21
+ const EvidenceReferenceSchema = z.object({
22
+ kind: z.enum(["artifact", "event", "verification", "receipt", "observation"]),
23
+ ref: z.string().min(1).max(512),
24
+ hash: z.string().regex(SHA256),
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
+ });
47
+ export const UsefulnessObservationSchema = z.object({
48
+ schema: z.literal(USEFULNESS_OBSERVATION_SCHEMA_VERSION),
49
+ observationId: z.string().regex(OBSERVATION_ID),
50
+ episode: z.object({
51
+ provider: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/),
52
+ schemaVersion: z.string().min(3).max(128),
53
+ episodeId: z.string().min(3).max(256),
54
+ episodeHash: z.string().regex(SHA256),
55
+ terminalAt: z.string().min(1).max(64),
56
+ result: z.enum(["pass", "fail", "uncertain", "abandoned", "rolled_back"]),
57
+ }).strict(),
58
+ delivery: z.object({
59
+ receiptRef: z.string().regex(RECEIPT_REF),
60
+ receiptHash: z.string().regex(SHA256),
61
+ graphRevision: z.string().regex(GIT_OBJECT),
62
+ sourceRevision: z.string().regex(GIT_OBJECT),
63
+ sourceContentHash: z.string().regex(SHA256),
64
+ }).strict(),
65
+ record: z.object({
66
+ recordId: z.string().min(3).max(512),
67
+ recordKind: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/),
68
+ recordRevision: z.string().regex(SHA256),
69
+ contentHash: z.string().regex(SHA256),
70
+ }).strict(),
71
+ /** Optional until hosts can produce it; when present it survives squash metadata. */
72
+ change: OutcomeChangeIdentitySchema.optional(),
73
+ signal: z.enum(USEFULNESS_SIGNALS),
74
+ evidence: z.array(EvidenceReferenceSchema).max(64),
75
+ observedAt: z.string().min(1).max(64),
76
+ retainUntil: z.string().min(1).max(64),
77
+ privacy: z.object({
78
+ payloadMode: z.literal("references_hashes_only"),
79
+ rawTranscriptIncluded: z.literal(false),
80
+ rawProviderOutputIncluded: z.literal(false),
81
+ }).strict(),
82
+ authority: z.object({
83
+ behavioralEffect: z.literal("none"),
84
+ mayChangeRanking: z.literal(false),
85
+ mayPromoteKnowledge: z.literal(false),
86
+ mayGrantAuthority: z.literal(false),
87
+ }).strict(),
88
+ contentHash: z.string().regex(SHA256),
89
+ }).strict().superRefine((observation, ctx) => {
90
+ const timestamps = [
91
+ ["episode", "terminalAt", observation.episode.terminalAt],
92
+ ["observedAt", observation.observedAt],
93
+ ["retainUntil", observation.retainUntil],
94
+ ];
95
+ for (const path of timestamps) {
96
+ const value = path.at(-1);
97
+ if (!Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) {
98
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: path.slice(0, -1), message: "usefulness timestamp is invalid" });
99
+ }
100
+ }
101
+ const observedAt = Date.parse(observation.observedAt);
102
+ const terminalAt = Date.parse(observation.episode.terminalAt);
103
+ const retainUntil = Date.parse(observation.retainUntil);
104
+ if (Number.isFinite(observedAt) && Number.isFinite(terminalAt) && observedAt < terminalAt) {
105
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["observedAt"], message: "usefulness cannot precede the terminal outcome" });
106
+ }
107
+ if (Number.isFinite(observedAt) && Number.isFinite(retainUntil)
108
+ && (retainUntil <= observedAt || retainUntil - observedAt > MAX_RETENTION_MS)) {
109
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["retainUntil"], message: "usefulness retention is invalid" });
110
+ }
111
+ if (observation.delivery.sourceRevision !== observation.delivery.graphRevision) {
112
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["delivery", "sourceRevision"], message: "usefulness source revision is inconsistent" });
113
+ }
114
+ if (observation.signal !== "unknown" && observation.evidence.length === 0) {
115
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["evidence"], message: "supported usefulness requires evidence" });
116
+ }
117
+ const evidenceIdentities = new Set();
118
+ for (const [index, evidence] of observation.evidence.entries()) {
119
+ const identity = `${evidence.kind}:${evidence.ref}:${evidence.hash}`;
120
+ if (evidenceIdentities.has(identity)) {
121
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["evidence", index], message: "usefulness evidence is duplicated" });
122
+ }
123
+ evidenceIdentities.add(identity);
124
+ if (!isCredentialFreeText(evidence.ref)) {
125
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["evidence", index, "ref"], message: "usefulness evidence contains credential material" });
126
+ }
127
+ }
128
+ for (const [path, value] of [
129
+ [["episode", "schemaVersion"], observation.episode.schemaVersion],
130
+ [["episode", "episodeId"], observation.episode.episodeId],
131
+ [["record", "recordId"], observation.record.recordId],
132
+ ]) {
133
+ if (!isCredentialFreeText(value)) {
134
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [...path], message: "usefulness identity contains credential material" });
135
+ }
136
+ }
137
+ const unsigned = usefulnessObservationUnsigned(observation);
138
+ const contentHash = usefulnessHash(unsigned);
139
+ if (observation.contentHash !== contentHash
140
+ || observation.observationId !== usefulnessObservationId(observation)) {
141
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "usefulness observation seal is invalid" });
142
+ }
143
+ });
144
+ function canonicalJson(value) {
145
+ if (Array.isArray(value))
146
+ return `[${value.map(canonicalJson).join(",")}]`;
147
+ if (value && typeof value === "object") {
148
+ return `{${Object.entries(value)
149
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
150
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
151
+ .join(",")}}`;
152
+ }
153
+ return JSON.stringify(value) ?? "null";
154
+ }
155
+ function usefulnessHash(value) {
156
+ return `sha256:${createHash("sha256").update(canonicalJson(value), "utf8").digest("hex")}`;
157
+ }
158
+ function usefulnessObservationId(observation) {
159
+ const identityHash = usefulnessHash({
160
+ episodeId: observation.episode.episodeId,
161
+ receiptRef: observation.delivery.receiptRef,
162
+ receiptHash: observation.delivery.receiptHash,
163
+ recordId: observation.record.recordId,
164
+ });
165
+ return `huo_${identityHash.slice(7, 31)}`;
166
+ }
167
+ function usefulnessObservationUnsigned(observation) {
168
+ return {
169
+ schema: observation.schema,
170
+ episode: { ...observation.episode },
171
+ delivery: { ...observation.delivery },
172
+ record: { ...observation.record },
173
+ ...(observation.change ? { change: { ...observation.change } } : {}),
174
+ signal: observation.signal,
175
+ evidence: observation.evidence.map((item) => ({ ...item })),
176
+ observedAt: observation.observedAt,
177
+ retainUntil: observation.retainUntil,
178
+ privacy: { ...observation.privacy },
179
+ authority: { ...observation.authority },
180
+ };
181
+ }
182
+ export function createUsefulnessObservation(input) {
183
+ const unsigned = usefulnessObservationUnsigned({
184
+ schema: USEFULNESS_OBSERVATION_SCHEMA_VERSION,
185
+ episode: { ...input.episode },
186
+ delivery: { ...input.delivery },
187
+ record: { ...input.record },
188
+ ...(input.change ? { change: { ...input.change } } : {}),
189
+ signal: input.signal,
190
+ evidence: input.evidence.map((item) => ({ ...item })),
191
+ observedAt: input.observedAt,
192
+ retainUntil: input.retainUntil,
193
+ privacy: { ...input.privacy },
194
+ authority: {
195
+ behavioralEffect: "none",
196
+ mayChangeRanking: false,
197
+ mayPromoteKnowledge: false,
198
+ mayGrantAuthority: false,
199
+ },
200
+ });
201
+ const observationId = usefulnessObservationId(unsigned);
202
+ return UsefulnessObservationSchema.parse({
203
+ ...unsigned,
204
+ observationId,
205
+ contentHash: usefulnessHash(unsigned),
206
+ });
207
+ }
208
+ export function assertUsefulnessObservation(value) {
209
+ UsefulnessObservationSchema.parse(value);
210
+ }
211
+ /** Contradiction and staleness become open advisory review work, never truth. */
212
+ export function usefulnessObservationFinding(value) {
213
+ const observation = UsefulnessObservationSchema.parse(value);
214
+ if (observation.signal !== "contradicted" && observation.signal !== "stale")
215
+ return null;
216
+ const title = `Outcome ${observation.signal}: ${observation.record.recordId} (${observation.observationId})`;
217
+ const evidence = [
218
+ `usefulness:${observation.observationId}`,
219
+ `usefulness-content:${observation.contentHash}`,
220
+ `episode:${observation.episode.episodeId}@${observation.episode.episodeHash}`,
221
+ `delivery:${observation.delivery.receiptRef}@${observation.delivery.receiptHash}`,
222
+ `record:${observation.record.recordId}@${observation.record.recordRevision}`,
223
+ ...(observation.change ? [`change:${observation.change.change_id}@${observation.change.content_hash}`] : []),
224
+ ...observation.evidence.map((item) => `${item.kind}:${item.ref}@${item.hash}`),
225
+ ];
226
+ return FindingSchema.parse({
227
+ id: findingId(title),
228
+ title,
229
+ observation: observation.signal === "contradicted"
230
+ ? `Outcome evidence may conflict with delivered Hunch record ${observation.record.recordId}; review the record and its applicability before changing trusted knowledge.`
231
+ : `Outcome evidence may show that delivered Hunch record ${observation.record.recordId} was stale; review currentness before changing trusted knowledge.`,
232
+ evidence,
233
+ method: null,
234
+ severity: observation.signal === "contradicted" ? "high" : "medium",
235
+ triage: "open",
236
+ affected_files: [],
237
+ affected_symbols: [observation.record.recordId],
238
+ violates_constraint: null,
239
+ spawned_decision: null,
240
+ observed_at: observation.observedAt,
241
+ resolved_commit: null,
242
+ provenance: {
243
+ source: "outcome_experience+candidate",
244
+ confidence: 0.75,
245
+ evidence,
246
+ last_verified: observation.observedAt,
247
+ },
248
+ });
249
+ }
250
+ //# sourceMappingURL=outcomeExperience.js.map
@@ -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 }));
@@ -35,6 +35,17 @@ export const ADR_DIR_CANDIDATES = [
35
35
  /** An ADR file is NNNN-slug.md (adr-tools / MADR convention). Templates and
36
36
  * indexes (adr-template.md, README.md, index.md) never match. */
37
37
  export const ADR_FILE_RE = /^(\d{1,5})-([a-z0-9@][a-z0-9@._-]*)\.md$/i;
38
+ /** A structurally valid ADR filename can still name corpus scaffolding rather
39
+ * than a decision. Keep this classification separate from parse failure so the
40
+ * CLI can exclude templates quietly instead of falsely calling their filename
41
+ * malformed. */
42
+ export function isAdrCorpusTemplate(relPath) {
43
+ const base = relPath.split("/").pop() ?? relPath;
44
+ const match = ADR_FILE_RE.exec(base);
45
+ return match !== null
46
+ && Number(match[1]) === 0
47
+ && /^(?:adr[-_.]?)?template(?:[-_.]|$)/i.test(match[2]);
48
+ }
38
49
  /** Decision.date is required even when an ADR has no date and no Git history is
39
50
  * available (for example, a copied corpus outside a repository). Use an honest,
40
51
  * deterministic sentinel instead of the import clock: re-importing the same
@@ -180,7 +191,7 @@ export function parseAdrMarkdown(text, relPath) {
180
191
  return null;
181
192
  const number = Number(nameMatch[1]);
182
193
  const slug = nameMatch[2].toLowerCase();
183
- if (number === 0 && /^(?:adr[-_.]?)?template(?:[-_.]|$)/i.test(slug))
194
+ if (isAdrCorpusTemplate(relPath))
184
195
  return null;
185
196
  const fmMatch = FRONTMATTER_RE.exec(text);
186
197
  const fm = fmMatch ? fmMatch[1] : "";
@@ -253,7 +264,9 @@ export function mapAdrCorpus(sources) {
253
264
  for (const src of sources) {
254
265
  const p = parseAdrMarkdown(src.text, src.relPath);
255
266
  if (!p) {
256
- warnings.push(`${src.relPath}: filename does not follow NNNN-slug.md — skipped`);
267
+ if (!isAdrCorpusTemplate(src.relPath)) {
268
+ warnings.push(`${src.relPath}: filename does not follow NNNN-slug.md — skipped`);
269
+ }
257
270
  continue;
258
271
  }
259
272
  parsed.push(p);
@@ -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 }],
@@ -786,6 +805,31 @@ export function buildServerWithRootControl(initialRoot) {
786
805
  }
787
806
  return ok(`Blast radius for "${target}":\n\n${parts.join("\n\n")}`);
788
807
  });
808
+ // -- hunch_change_identity (squash-stable exact delta identity) ------------
809
+ server.registerTool("hunch_change_identity", {
810
+ title: "Derive an exact squash-stable change identity",
811
+ 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.",
812
+ inputSchema: {
813
+ base_ref: z.string().min(1).max(1_024).describe("Base commit or ref for the exact tree transition."),
814
+ head_ref: z.string().min(1).max(1_024).optional().describe("Head commit or ref (default HEAD)."),
815
+ cwd: cwdHintField,
816
+ },
817
+ outputSchema: CHANGE_IDENTITY_OUTPUT_SCHEMA,
818
+ }, async ({ base_ref, head_ref }) => {
819
+ try {
820
+ const identity = CHANGE_IDENTITY_OUTPUT_SCHEMA.parse(deriveChangeIdentity(root, base_ref, head_ref ?? "HEAD"));
821
+ return {
822
+ content: [{
823
+ type: "text",
824
+ text: `${identity.change_id} — ${identity.file_count} exact file delta(s), ${identity.delta_hash}; sealed ${identity.content_hash}`,
825
+ }],
826
+ structuredContent: identity,
827
+ };
828
+ }
829
+ catch (error) {
830
+ return err(error.message);
831
+ }
832
+ });
789
833
  // -- hunch_context (surgical retrieval) -----------------------------------
790
834
  server.registerTool("hunch_context", {
791
835
  title: "Assemble the minimal relevant Hunch slice for a task",
@@ -793,10 +837,11 @@ export function buildServerWithRootControl(initialRoot) {
793
837
  inputSchema: {
794
838
  target: z.string().describe("A file path, symbol, or task phrase you're about to work on."),
795
839
  budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
840
+ profile: z.enum(DELIVERY_PROFILES).optional().describe("Delivery role: builder (default), reviewer, or architect. Changes non-blocking order only."),
796
841
  as_of: z.string().optional().describe("Time-travel ref (commit/tag/branch): assemble the slice as it stood then."),
797
842
  },
798
843
  outputSchema: DELIVERY_OUTPUT_SCHEMA,
799
- }, async ({ target, budget_tokens, as_of }, extra) => {
844
+ }, async ({ target, budget_tokens, profile, as_of }, extra) => {
800
845
  const asOf = as_of ? asOfDate(as_of, root) : undefined;
801
846
  if (as_of && !asOf)
802
847
  return err(`Could not resolve as_of "${as_of}" to a commit.`);
@@ -807,6 +852,7 @@ export function buildServerWithRootControl(initialRoot) {
807
852
  components: store.recs("components"),
808
853
  decisionCorpus: store.recs("decisions"),
809
854
  historical: !!asOf,
855
+ profile: profile ?? "builder",
810
856
  };
811
857
  // Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
812
858
  // used to return an empty brief while the graph held the answer — fall back to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.20.0-rc.4",
3
+ "version": "1.20.0-rc.6",
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.4",
10
+ "version": "1.20.0-rc.6",
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.4",
16
+ "version": "1.20.0-rc.6",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {