@davesheffer/hunch 1.20.0-rc.5 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- 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 +27 -0
- package/dist/core/served.js +5 -3
- package/dist/integrations/claudemd.js +1 -1
- package/dist/mcp/server.js +87 -16
- 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 |
|
|
@@ -169,7 +171,7 @@ repository, separate from the code repository. Hunch does not host it. Create a
|
|
|
169
171
|
that every teammate can access, install Hunch on team machines and CI, then have one maintainer run:
|
|
170
172
|
|
|
171
173
|
```bash
|
|
172
|
-
npm i -g @davesheffer/hunch@1.
|
|
174
|
+
npm i -g @davesheffer/hunch@1.20.0
|
|
173
175
|
hunch shared --repo git@github.com:acme/project-hunch-memory.git
|
|
174
176
|
git add .gitignore .hunch/team.json
|
|
175
177
|
git commit -m "chore: connect shared Hunch memory"
|
|
@@ -184,7 +186,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
|
|
|
184
186
|
After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
|
|
185
187
|
|
|
186
188
|
```bash
|
|
187
|
-
npm i -g @davesheffer/hunch@1.
|
|
189
|
+
npm i -g @davesheffer/hunch@1.20.0
|
|
188
190
|
git pull
|
|
189
191
|
hunch init
|
|
190
192
|
hunch doctor
|
|
@@ -232,7 +234,7 @@ but stops automatic memory commits and pushes. As a team-coordinated rollback, r
|
|
|
232
234
|
commit to stop discovery after teammates pull the revert. Existing machines retain their ignored
|
|
233
235
|
local overlay until they are deliberately disconnected; do not delete the memory repo as part of a
|
|
234
236
|
rollback. For this rollout, reinstall the previous published package with
|
|
235
|
-
`npm i -g @davesheffer/hunch@1.
|
|
237
|
+
`npm i -g @davesheffer/hunch@1.19.0`; the release receipt resolves and records the verified rollback
|
|
236
238
|
target from the npm registry instead of trusting Git tags. Pause enforcement first as shown above,
|
|
237
239
|
and keep every team client on the same release before resuming Matrix policy workflows.
|
|
238
240
|
|
package/dist/cli/index.js
CHANGED
|
@@ -55,7 +55,8 @@ import { formatContext, formatStructure } from "../core/format.js";
|
|
|
55
55
|
import { diagnoseIssueCorrectionStage, formatCorrectionStageDiagnostic } from "../core/correctionStage.js";
|
|
56
56
|
import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/evidenceMap.js";
|
|
57
57
|
import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
|
|
58
|
-
import { buildDeliveryEnvelope } from "../core/delivery.js";
|
|
58
|
+
import { buildDeliveryEnvelope, DELIVERY_PROFILES } from "../core/delivery.js";
|
|
59
|
+
import { deriveChangeIdentity } from "../core/changeIdentity.js";
|
|
59
60
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
60
61
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
61
62
|
import { isHumanConfirmed } from "../core/strictgate.js";
|
|
@@ -3819,14 +3820,46 @@ program
|
|
|
3819
3820
|
fail(`could not compile the verified evidence map: ${error.message}`);
|
|
3820
3821
|
}
|
|
3821
3822
|
});
|
|
3823
|
+
// ---- change-id (squash-stable exact delta identity) -----------------------
|
|
3824
|
+
program
|
|
3825
|
+
.command("change-id")
|
|
3826
|
+
.description("Derive an exact change identity that survives commit-message and squash metadata changes.")
|
|
3827
|
+
.argument("<base>", "base commit or ref")
|
|
3828
|
+
.argument("[head]", "head commit or ref", "HEAD")
|
|
3829
|
+
.option("--json", "emit the complete sealed identity as JSON")
|
|
3830
|
+
.action((base, head, opts) => {
|
|
3831
|
+
try {
|
|
3832
|
+
const identity = deriveChangeIdentity(findRoot(), base, head);
|
|
3833
|
+
if (opts.json) {
|
|
3834
|
+
console.log(JSON.stringify(identity, null, 2));
|
|
3835
|
+
}
|
|
3836
|
+
else {
|
|
3837
|
+
console.log([
|
|
3838
|
+
`${identity.change_id} (${identity.algorithm})`,
|
|
3839
|
+
` exact revisions: ${identity.base_revision}..${identity.head_revision}`,
|
|
3840
|
+
` exact delta: ${identity.delta_hash}`,
|
|
3841
|
+
` Git stable patch ID: ${identity.patch_id ?? "unavailable (exact Hunch identity still valid)"}`,
|
|
3842
|
+
` files: ${identity.file_count} (${identity.paths_hash})`,
|
|
3843
|
+
` sealed receipt: ${identity.content_hash}`,
|
|
3844
|
+
].join("\n"));
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
catch (error) {
|
|
3848
|
+
fail(error.message);
|
|
3849
|
+
}
|
|
3850
|
+
});
|
|
3822
3851
|
// ---- context (surgical retrieval) -----------------------------------------
|
|
3823
3852
|
program
|
|
3824
3853
|
.command("context")
|
|
3825
3854
|
.description("Assemble the minimal relevant Hunch slice for a task on a file/symbol.")
|
|
3826
3855
|
.argument("<target>", "file path or symbol")
|
|
3827
3856
|
.option("--budget <n>", "rough token budget", "1500")
|
|
3857
|
+
.option("--profile <profile>", "delivery role: builder, reviewer, or architect", "builder")
|
|
3828
3858
|
.option("--as-of <ref>", "time-travel: assemble the slice as it stood at a commit/tag/branch")
|
|
3829
3859
|
.action(async (target, opts) => {
|
|
3860
|
+
if (!DELIVERY_PROFILES.includes(opts.profile)) {
|
|
3861
|
+
return fail(`--profile must be one of: ${DELIVERY_PROFILES.join(", ")}`);
|
|
3862
|
+
}
|
|
3830
3863
|
const { store, root } = storeFor();
|
|
3831
3864
|
const asOf = opts.asOf ? asOfDate(opts.asOf, root) : undefined;
|
|
3832
3865
|
if (opts.asOf && !asOf)
|
|
@@ -3861,6 +3894,7 @@ program
|
|
|
3861
3894
|
components: store.recs("components"),
|
|
3862
3895
|
decisionCorpus: store.recs("decisions"),
|
|
3863
3896
|
historical: !!asOf,
|
|
3897
|
+
profile: opts.profile,
|
|
3864
3898
|
}));
|
|
3865
3899
|
store.close();
|
|
3866
3900
|
});
|
|
@@ -4464,6 +4498,7 @@ program
|
|
|
4464
4498
|
if (!hasContent)
|
|
4465
4499
|
return; // no noise on files Hunch hasn't learned yet
|
|
4466
4500
|
const envelope = buildDeliveryEnvelope(ctx, {
|
|
4501
|
+
profile: "builder",
|
|
4467
4502
|
root,
|
|
4468
4503
|
symbols: store.recs("symbols"),
|
|
4469
4504
|
components: store.recs("components"),
|
|
@@ -4495,6 +4530,8 @@ program
|
|
|
4495
4530
|
delivery_reason: item.delivery_reason,
|
|
4496
4531
|
provenance_status: item.provenance_status,
|
|
4497
4532
|
token_cost: item.token_cost,
|
|
4533
|
+
delivery_profile: envelope.profile,
|
|
4534
|
+
ranking_policy: envelope.ranking_policy,
|
|
4498
4535
|
})));
|
|
4499
4536
|
if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
|
|
4500
4537
|
receipts("refreshed");
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit-metadata-independent identity for one exact repository change.
|
|
3
|
+
*
|
|
4
|
+
* The identity hashes Git's raw tree delta (modes, paths and blob object IDs),
|
|
5
|
+
* not a commit SHA or prose diff. A branch range and its squash commit therefore
|
|
6
|
+
* share one change ID when they produce the same exact tree transition. Unlike
|
|
7
|
+
* `git patch-id`, whitespace-only, binary and mode changes remain significant.
|
|
8
|
+
*/
|
|
9
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { compareCodeUnits } from "./canonicalOrder.js";
|
|
12
|
+
export const CHANGE_IDENTITY_SCHEMA_VERSION = "hunch.change-identity/1";
|
|
13
|
+
export const CHANGE_IDENTITY_ALGORITHM = "git-raw-tree-delta-sha256/1";
|
|
14
|
+
const GIT_OBJECT = /^[a-f0-9]{40,64}$/;
|
|
15
|
+
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
16
|
+
const CHANGE_ID = /^hchg_[a-f0-9]{24}$/;
|
|
17
|
+
const MAX_GIT_OUTPUT = 64 * 1024 * 1024;
|
|
18
|
+
const MAX_CHANGED_FILES = 16_384;
|
|
19
|
+
function canonical(value) {
|
|
20
|
+
if (Array.isArray(value))
|
|
21
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
22
|
+
if (value && typeof value === "object") {
|
|
23
|
+
return `{${Object.entries(value)
|
|
24
|
+
.filter(([, child]) => child !== undefined)
|
|
25
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
26
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
|
|
27
|
+
.join(",")}}`;
|
|
28
|
+
}
|
|
29
|
+
return JSON.stringify(value) ?? "null";
|
|
30
|
+
}
|
|
31
|
+
function sha256(value) {
|
|
32
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
33
|
+
}
|
|
34
|
+
function gitEnvironment() {
|
|
35
|
+
const environment = { ...process.env, GIT_NO_REPLACE_OBJECTS: "1", LC_ALL: "C", LANG: "C" };
|
|
36
|
+
for (const name of [
|
|
37
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
|
|
38
|
+
"GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
|
|
39
|
+
"GIT_INDEX_FILE", "GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_INTERNAL_SUPER_PREFIX",
|
|
40
|
+
"GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
|
|
41
|
+
])
|
|
42
|
+
delete environment[name];
|
|
43
|
+
for (const name of Object.keys(environment)) {
|
|
44
|
+
if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(name))
|
|
45
|
+
delete environment[name];
|
|
46
|
+
}
|
|
47
|
+
return environment;
|
|
48
|
+
}
|
|
49
|
+
function gitText(root, args, input) {
|
|
50
|
+
try {
|
|
51
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
env: gitEnvironment(),
|
|
54
|
+
input,
|
|
55
|
+
maxBuffer: MAX_GIT_OUTPUT,
|
|
56
|
+
stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
|
|
57
|
+
timeout: 15_000,
|
|
58
|
+
}).trim();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const stderr = error.stderr?.toString("utf8").trim().replace(/[\r\n]+/g, " ");
|
|
62
|
+
throw new Error(`could not derive exact Git change identity${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function gitBytes(root, args) {
|
|
66
|
+
try {
|
|
67
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
68
|
+
encoding: "buffer",
|
|
69
|
+
env: gitEnvironment(),
|
|
70
|
+
maxBuffer: MAX_GIT_OUTPUT,
|
|
71
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
72
|
+
timeout: 15_000,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const stderr = error.stderr?.toString("utf8").trim().replace(/[\r\n]+/g, " ");
|
|
77
|
+
throw new Error(`could not derive exact Git change identity${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function exactCommit(root, ref) {
|
|
81
|
+
if (!ref.trim() || /[\0\r\n]/.test(ref) || ref.length > 1_024)
|
|
82
|
+
throw new Error("Git revision is invalid");
|
|
83
|
+
const revision = gitText(root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]);
|
|
84
|
+
if (!GIT_OBJECT.test(revision))
|
|
85
|
+
throw new Error("Git did not return an exact commit object");
|
|
86
|
+
return revision;
|
|
87
|
+
}
|
|
88
|
+
function patchId(root, base, head) {
|
|
89
|
+
const diff = gitBytes(root, [
|
|
90
|
+
"diff", "--binary", "--full-index", "--no-renames", "--no-ext-diff", "--no-textconv",
|
|
91
|
+
base, head, "--",
|
|
92
|
+
]);
|
|
93
|
+
const result = spawnSync("git", ["patch-id", "--stable"], {
|
|
94
|
+
cwd: root,
|
|
95
|
+
env: gitEnvironment(),
|
|
96
|
+
input: diff,
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
maxBuffer: MAX_GIT_OUTPUT,
|
|
99
|
+
timeout: 15_000,
|
|
100
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
101
|
+
});
|
|
102
|
+
if (result.error || result.status !== 0)
|
|
103
|
+
throw new Error("could not derive interoperable Git patch ID");
|
|
104
|
+
const value = result.stdout.trim().split(/\s+/)[0] ?? "";
|
|
105
|
+
if (!value)
|
|
106
|
+
return null;
|
|
107
|
+
if (!GIT_OBJECT.test(value))
|
|
108
|
+
throw new Error("Git returned an invalid patch ID");
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
/** Derive one deterministic receipt from two commit-ish references. */
|
|
112
|
+
export function deriveChangeIdentity(root, baseRef, headRef = "HEAD") {
|
|
113
|
+
const baseRevision = exactCommit(root, baseRef);
|
|
114
|
+
const headRevision = exactCommit(root, headRef);
|
|
115
|
+
const baseTree = gitText(root, ["rev-parse", "--verify", `${baseRevision}^{tree}`]);
|
|
116
|
+
const headTree = gitText(root, ["rev-parse", "--verify", `${headRevision}^{tree}`]);
|
|
117
|
+
if (!GIT_OBJECT.test(baseTree) || !GIT_OBJECT.test(headTree))
|
|
118
|
+
throw new Error("Git tree identity is invalid");
|
|
119
|
+
const rawDelta = gitBytes(root, [
|
|
120
|
+
"diff-tree", "--no-commit-id", "--raw", "--full-index", "-r", "-z", "--no-renames",
|
|
121
|
+
baseRevision, headRevision, "--",
|
|
122
|
+
]);
|
|
123
|
+
if (rawDelta.byteLength === 0)
|
|
124
|
+
throw new Error("exact Git change is empty");
|
|
125
|
+
const rawPaths = gitBytes(root, ["diff", "--name-only", "-z", "--no-renames", baseRevision, headRevision, "--"]);
|
|
126
|
+
const pathLengths = [];
|
|
127
|
+
let start = 0;
|
|
128
|
+
for (let index = 0; index < rawPaths.length; index++) {
|
|
129
|
+
if (rawPaths[index] !== 0)
|
|
130
|
+
continue;
|
|
131
|
+
pathLengths.push(index - start);
|
|
132
|
+
start = index + 1;
|
|
133
|
+
}
|
|
134
|
+
if (!rawPaths.length || rawPaths[rawPaths.length - 1] !== 0 || start !== rawPaths.length
|
|
135
|
+
|| !pathLengths.length || pathLengths.length > MAX_CHANGED_FILES
|
|
136
|
+
|| pathLengths.some((length) => length < 1 || length > 4_096)) {
|
|
137
|
+
throw new Error("exact Git change has an invalid or unbounded file set");
|
|
138
|
+
}
|
|
139
|
+
const deltaHash = sha256(rawDelta);
|
|
140
|
+
const changeSeed = canonical({ algorithm: CHANGE_IDENTITY_ALGORITHM, delta_hash: deltaHash });
|
|
141
|
+
const changeId = `hchg_${sha256(changeSeed).slice("sha256:".length, "sha256:".length + 24)}`;
|
|
142
|
+
const unsigned = {
|
|
143
|
+
schema: CHANGE_IDENTITY_SCHEMA_VERSION,
|
|
144
|
+
algorithm: CHANGE_IDENTITY_ALGORITHM,
|
|
145
|
+
change_id: changeId,
|
|
146
|
+
base_revision: baseRevision,
|
|
147
|
+
head_revision: headRevision,
|
|
148
|
+
base_tree: baseTree,
|
|
149
|
+
head_tree: headTree,
|
|
150
|
+
delta_hash: deltaHash,
|
|
151
|
+
patch_id: patchId(root, baseRevision, headRevision),
|
|
152
|
+
file_count: pathLengths.length,
|
|
153
|
+
paths_hash: sha256(rawPaths),
|
|
154
|
+
};
|
|
155
|
+
const identity = { ...unsigned, content_hash: sha256(canonical(unsigned)) };
|
|
156
|
+
assertChangeIdentity(identity);
|
|
157
|
+
return identity;
|
|
158
|
+
}
|
|
159
|
+
/** Validate an identity without trusting any caller-supplied seal. */
|
|
160
|
+
export function assertChangeIdentity(value) {
|
|
161
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
162
|
+
throw new Error("change identity is invalid");
|
|
163
|
+
const identity = value;
|
|
164
|
+
const expectedFields = [
|
|
165
|
+
"schema", "algorithm", "change_id", "base_revision", "head_revision", "base_tree", "head_tree",
|
|
166
|
+
"delta_hash", "patch_id", "file_count", "paths_hash", "content_hash",
|
|
167
|
+
];
|
|
168
|
+
if (Object.keys(value).sort(compareCodeUnits).join("\0") !== expectedFields.sort(compareCodeUnits).join("\0")
|
|
169
|
+
|| identity.schema !== CHANGE_IDENTITY_SCHEMA_VERSION || identity.algorithm !== CHANGE_IDENTITY_ALGORITHM
|
|
170
|
+
|| !CHANGE_ID.test(identity.change_id) || !GIT_OBJECT.test(identity.base_revision)
|
|
171
|
+
|| !GIT_OBJECT.test(identity.head_revision) || !GIT_OBJECT.test(identity.base_tree)
|
|
172
|
+
|| !GIT_OBJECT.test(identity.head_tree) || !SHA256.test(identity.delta_hash)
|
|
173
|
+
|| (identity.patch_id !== null && !GIT_OBJECT.test(identity.patch_id))
|
|
174
|
+
|| !Number.isSafeInteger(identity.file_count) || identity.file_count < 1 || identity.file_count > MAX_CHANGED_FILES
|
|
175
|
+
|| !SHA256.test(identity.paths_hash) || !SHA256.test(identity.content_hash)) {
|
|
176
|
+
throw new Error("change identity fields are invalid");
|
|
177
|
+
}
|
|
178
|
+
const expectedChange = `hchg_${sha256(canonical({ algorithm: identity.algorithm, delta_hash: identity.delta_hash }))
|
|
179
|
+
.slice("sha256:".length, "sha256:".length + 24)}`;
|
|
180
|
+
const { content_hash: _contentHash, ...unsigned } = identity;
|
|
181
|
+
if (identity.change_id !== expectedChange || identity.content_hash !== sha256(canonical(unsigned))) {
|
|
182
|
+
throw new Error("change identity seal is invalid");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export function changesAreEquivalent(left, right) {
|
|
186
|
+
assertChangeIdentity(left);
|
|
187
|
+
assertChangeIdentity(right);
|
|
188
|
+
return left.algorithm === right.algorithm
|
|
189
|
+
&& left.change_id === right.change_id
|
|
190
|
+
&& left.delta_hash === right.delta_hash;
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=changeIdentity.js.map
|
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,
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { findingId } from "./ids.js";
|
|
4
4
|
import { FindingSchema, isCredentialFreeText } from "./types.js";
|
|
5
|
+
import { assertChangeIdentity, CHANGE_IDENTITY_ALGORITHM, CHANGE_IDENTITY_SCHEMA_VERSION, } from "./changeIdentity.js";
|
|
5
6
|
export const USEFULNESS_OBSERVATION_SCHEMA_VERSION = "hunch.usefulness-observation/1";
|
|
6
7
|
export const USEFULNESS_SIGNALS = [
|
|
7
8
|
"used",
|
|
@@ -22,6 +23,27 @@ const EvidenceReferenceSchema = z.object({
|
|
|
22
23
|
ref: z.string().min(1).max(512),
|
|
23
24
|
hash: z.string().regex(SHA256),
|
|
24
25
|
}).strict();
|
|
26
|
+
const OutcomeChangeIdentitySchema = z.object({
|
|
27
|
+
schema: z.literal(CHANGE_IDENTITY_SCHEMA_VERSION),
|
|
28
|
+
algorithm: z.literal(CHANGE_IDENTITY_ALGORITHM),
|
|
29
|
+
change_id: z.string().regex(/^hchg_[a-f0-9]{24}$/),
|
|
30
|
+
base_revision: z.string().regex(GIT_OBJECT),
|
|
31
|
+
head_revision: z.string().regex(GIT_OBJECT),
|
|
32
|
+
base_tree: z.string().regex(GIT_OBJECT),
|
|
33
|
+
head_tree: z.string().regex(GIT_OBJECT),
|
|
34
|
+
delta_hash: z.string().regex(SHA256),
|
|
35
|
+
patch_id: z.string().regex(GIT_OBJECT).nullable(),
|
|
36
|
+
file_count: z.number().int().positive().max(16_384),
|
|
37
|
+
paths_hash: z.string().regex(SHA256),
|
|
38
|
+
content_hash: z.string().regex(SHA256),
|
|
39
|
+
}).strict().superRefine((identity, ctx) => {
|
|
40
|
+
try {
|
|
41
|
+
assertChangeIdentity(identity);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: error.message });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
25
47
|
export const UsefulnessObservationSchema = z.object({
|
|
26
48
|
schema: z.literal(USEFULNESS_OBSERVATION_SCHEMA_VERSION),
|
|
27
49
|
observationId: z.string().regex(OBSERVATION_ID),
|
|
@@ -46,6 +68,8 @@ export const UsefulnessObservationSchema = z.object({
|
|
|
46
68
|
recordRevision: z.string().regex(SHA256),
|
|
47
69
|
contentHash: z.string().regex(SHA256),
|
|
48
70
|
}).strict(),
|
|
71
|
+
/** Optional until hosts can produce it; when present it survives squash metadata. */
|
|
72
|
+
change: OutcomeChangeIdentitySchema.optional(),
|
|
49
73
|
signal: z.enum(USEFULNESS_SIGNALS),
|
|
50
74
|
evidence: z.array(EvidenceReferenceSchema).max(64),
|
|
51
75
|
observedAt: z.string().min(1).max(64),
|
|
@@ -146,6 +170,7 @@ function usefulnessObservationUnsigned(observation) {
|
|
|
146
170
|
episode: { ...observation.episode },
|
|
147
171
|
delivery: { ...observation.delivery },
|
|
148
172
|
record: { ...observation.record },
|
|
173
|
+
...(observation.change ? { change: { ...observation.change } } : {}),
|
|
149
174
|
signal: observation.signal,
|
|
150
175
|
evidence: observation.evidence.map((item) => ({ ...item })),
|
|
151
176
|
observedAt: observation.observedAt,
|
|
@@ -160,6 +185,7 @@ export function createUsefulnessObservation(input) {
|
|
|
160
185
|
episode: { ...input.episode },
|
|
161
186
|
delivery: { ...input.delivery },
|
|
162
187
|
record: { ...input.record },
|
|
188
|
+
...(input.change ? { change: { ...input.change } } : {}),
|
|
163
189
|
signal: input.signal,
|
|
164
190
|
evidence: input.evidence.map((item) => ({ ...item })),
|
|
165
191
|
observedAt: input.observedAt,
|
|
@@ -194,6 +220,7 @@ export function usefulnessObservationFinding(value) {
|
|
|
194
220
|
`episode:${observation.episode.episodeId}@${observation.episode.episodeHash}`,
|
|
195
221
|
`delivery:${observation.delivery.receiptRef}@${observation.delivery.receiptHash}`,
|
|
196
222
|
`record:${observation.record.recordId}@${observation.record.recordRevision}`,
|
|
223
|
+
...(observation.change ? [`change:${observation.change.change_id}@${observation.change.content_hash}`] : []),
|
|
197
224
|
...observation.evidence.map((item) => `${item.kind}:${item.ref}@${item.hash}`),
|
|
198
225
|
];
|
|
199
226
|
return FindingSchema.parse({
|
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 }));
|
|
@@ -46,7 +46,7 @@ export function renderHunchSection(store, root) {
|
|
|
46
46
|
lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
|
|
47
47
|
lines.push("");
|
|
48
48
|
lines.push("**Orient (session/task start):**");
|
|
49
|
-
lines.push("- `hunch_context(
|
|
49
|
+
lines.push("- `hunch_context(target)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST.**");
|
|
50
50
|
lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
|
|
51
51
|
lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task, before re-deriving them.");
|
|
52
52
|
lines.push("- `hunch_escalations()` — the decisions only the HUMAN can make (including one exact imported ADR at a time, topic conflicts, and policy calls). Normally empty; when it isn't, ASK the user inline — an entry is a question, silence is never approval. Apply an ADR answer only through `hunch_review_imported_adr` with its printed source and review hashes.");
|
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 }],
|
|
@@ -458,14 +477,22 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
458
477
|
};
|
|
459
478
|
const pullTeamMemory = (force = false) => {
|
|
460
479
|
if (!store.privateDir)
|
|
461
|
-
return;
|
|
480
|
+
return "not_shared";
|
|
462
481
|
const now = Date.now();
|
|
463
482
|
if (!force && now < nextRemotePullAt)
|
|
464
|
-
return;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
483
|
+
return "cooldown";
|
|
484
|
+
let status;
|
|
485
|
+
try {
|
|
486
|
+
status = pullHunchStatus(store.privateDir, {
|
|
487
|
+
timeoutMs: 5_000,
|
|
488
|
+
remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
status = "failed";
|
|
493
|
+
}
|
|
494
|
+
notePull(status, Date.now());
|
|
495
|
+
return status;
|
|
469
496
|
};
|
|
470
497
|
// A source stamp is acknowledged ONLY after a stable, successful rebuild. If
|
|
471
498
|
// another process changes the atomic JSON tree during the rebuild, retry once;
|
|
@@ -607,10 +634,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
607
634
|
return err("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
|
|
608
635
|
}
|
|
609
636
|
if (store.mode === "shared" && store.privateDir) {
|
|
610
|
-
|
|
611
|
-
pullTeamMemory();
|
|
612
|
-
}
|
|
613
|
-
catch { /* offline / lock held / invalid remote — use local */ }
|
|
637
|
+
pullTeamMemory();
|
|
614
638
|
// Recompute the full semantic + physical snapshot after the synchronous
|
|
615
639
|
// network seam. A paired team.json/origin change can occur while fetch is
|
|
616
640
|
// blocked; serving after that race would attach the old checkout to a new
|
|
@@ -786,6 +810,31 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
786
810
|
}
|
|
787
811
|
return ok(`Blast radius for "${target}":\n\n${parts.join("\n\n")}`);
|
|
788
812
|
});
|
|
813
|
+
// -- hunch_change_identity (squash-stable exact delta identity) ------------
|
|
814
|
+
server.registerTool("hunch_change_identity", {
|
|
815
|
+
title: "Derive an exact squash-stable change identity",
|
|
816
|
+
description: "Content-address the exact Git tree delta between two revisions. Commit messages, authors and squash metadata do not affect the identity; whitespace, paths, modes and blob bytes do. Read-only and deterministic.",
|
|
817
|
+
inputSchema: {
|
|
818
|
+
base_ref: z.string().min(1).max(1_024).describe("Base commit or ref for the exact tree transition."),
|
|
819
|
+
head_ref: z.string().min(1).max(1_024).optional().describe("Head commit or ref (default HEAD)."),
|
|
820
|
+
cwd: cwdHintField,
|
|
821
|
+
},
|
|
822
|
+
outputSchema: CHANGE_IDENTITY_OUTPUT_SCHEMA,
|
|
823
|
+
}, async ({ base_ref, head_ref }) => {
|
|
824
|
+
try {
|
|
825
|
+
const identity = CHANGE_IDENTITY_OUTPUT_SCHEMA.parse(deriveChangeIdentity(root, base_ref, head_ref ?? "HEAD"));
|
|
826
|
+
return {
|
|
827
|
+
content: [{
|
|
828
|
+
type: "text",
|
|
829
|
+
text: `${identity.change_id} — ${identity.file_count} exact file delta(s), ${identity.delta_hash}; sealed ${identity.content_hash}`,
|
|
830
|
+
}],
|
|
831
|
+
structuredContent: identity,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
return err(error.message);
|
|
836
|
+
}
|
|
837
|
+
});
|
|
789
838
|
// -- hunch_context (surgical retrieval) -----------------------------------
|
|
790
839
|
server.registerTool("hunch_context", {
|
|
791
840
|
title: "Assemble the minimal relevant Hunch slice for a task",
|
|
@@ -793,10 +842,11 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
793
842
|
inputSchema: {
|
|
794
843
|
target: z.string().describe("A file path, symbol, or task phrase you're about to work on."),
|
|
795
844
|
budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
|
|
845
|
+
profile: z.enum(DELIVERY_PROFILES).optional().describe("Delivery role: builder (default), reviewer, or architect. Changes non-blocking order only."),
|
|
796
846
|
as_of: z.string().optional().describe("Time-travel ref (commit/tag/branch): assemble the slice as it stood then."),
|
|
797
847
|
},
|
|
798
848
|
outputSchema: DELIVERY_OUTPUT_SCHEMA,
|
|
799
|
-
}, async ({ target, budget_tokens, as_of }, extra) => {
|
|
849
|
+
}, async ({ target, budget_tokens, profile, as_of }, extra) => {
|
|
800
850
|
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
801
851
|
if (as_of && !asOf)
|
|
802
852
|
return err(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
@@ -807,6 +857,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
807
857
|
components: store.recs("components"),
|
|
808
858
|
decisionCorpus: store.recs("decisions"),
|
|
809
859
|
historical: !!asOf,
|
|
860
|
+
profile: profile ?? "builder",
|
|
810
861
|
};
|
|
811
862
|
// Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
|
|
812
863
|
// used to return an empty brief while the graph held the answer — fall back to
|
|
@@ -1041,11 +1092,31 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1041
1092
|
// -- hunch_current_decision (decision-grounding: current(topic)) ----------
|
|
1042
1093
|
server.registerTool("hunch_current_decision", {
|
|
1043
1094
|
title: "Current decision for a topic",
|
|
1044
|
-
description: "Decision-grounding: return the single CURRENT (accepted, non-superseded) decision anchored to a topic — the authoritative answer a doc or diff is checked against, plus what it rejected.
|
|
1095
|
+
description: "Decision-grounding: return the single CURRENT (accepted, non-superseded) decision anchored to a topic — the authoritative answer a doc or diff is checked against, plus what it rejected. A shared-store miss is confirmed against fresh team memory before Hunch says the topic has no current decision. If freshness is unavailable, or a topic has an unresolved collision (>1 live), it injects nothing (fail-safe).",
|
|
1045
1096
|
inputSchema: { topic: z.string().describe("the decision anchor, e.g. 'auth-transport'") },
|
|
1046
1097
|
}, async ({ topic }) => {
|
|
1047
|
-
|
|
1048
|
-
|
|
1098
|
+
let decs = store.recs("decisions");
|
|
1099
|
+
let live = liveForTopic(decs, topic);
|
|
1100
|
+
// A prior transient fetch failure may have placed ordinary tool traffic in
|
|
1101
|
+
// backoff. That is acceptable for cached positive reads, but an exact topic
|
|
1102
|
+
// miss is an authority claim: local absence must never be presented as team
|
|
1103
|
+
// absence. Bypass the cooldown once, then reread only after a successful
|
|
1104
|
+
// bounded convergence. An unavailable remote produces an explicit abstention.
|
|
1105
|
+
if (live.length === 0 && store.mode === "shared" && store.privateDir) {
|
|
1106
|
+
const status = pullTeamMemory(true);
|
|
1107
|
+
if (status !== "updated" && status !== "current") {
|
|
1108
|
+
return err(`Shared team memory refresh is ${status}; Hunch cannot confirm that topic "${topic}" has no current decision. Retry when the shared store is available.`);
|
|
1109
|
+
}
|
|
1110
|
+
try {
|
|
1111
|
+
if (store.sourceStamp() !== indexedSourceStamp)
|
|
1112
|
+
refreshIndex();
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
return err(`Shared team memory refreshed, but its derived index could not be rebuilt; Hunch cannot confirm that topic "${topic}" is absent. Retry this read.`);
|
|
1116
|
+
}
|
|
1117
|
+
decs = store.recs("decisions");
|
|
1118
|
+
live = liveForTopic(decs, topic);
|
|
1119
|
+
}
|
|
1049
1120
|
if (live.length === 0)
|
|
1050
1121
|
return ok(`No current decision for topic "${topic}". (Un-anchored, or never captured.)`);
|
|
1051
1122
|
if (live.length > 1) {
|
package/package.json
CHANGED
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
|
|
10
|
+
"version": "1.20.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.20.0
|
|
16
|
+
"version": "1.20.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|