@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 +2 -0
- package/dist/cli/index.js +38 -1
- package/dist/core/changeIdentity.js +192 -0
- package/dist/core/delivery.js +68 -12
- package/dist/core/outcomeExperience.js +250 -0
- package/dist/core/served.js +5 -3
- package/dist/extractors/adrImport.js +15 -2
- package/dist/mcp/server.js +49 -3
- package/package.json +1 -1
- package/server.json +2 -2
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
|
package/dist/core/delivery.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
621
|
-
|
|
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
|
-
:
|
|
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
|
package/dist/core/served.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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);
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
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.
|
|
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.
|
|
16
|
+
"version": "1.20.0-rc.6",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|