@davesheffer/hunch 1.20.2 → 1.21.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 +6 -3
- package/dist/cli/dna.js +116 -0
- package/dist/cli/index.js +114 -3
- package/dist/core/projectDna.js +478 -0
- package/dist/core/projectDnaDelivery.js +54 -0
- package/dist/core/projectDnaDelta.js +159 -0
- package/dist/extractors/git.js +47 -0
- package/dist/mcp/server.js +162 -9
- package/dist/projectDna.js +10 -0
- package/dist/store/hunchStore.js +112 -5
- package/package.json +9 -1
- package/server.json +2 -2
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { compareCodeUnits } from "./canonicalOrder.js";
|
|
3
|
+
import { assertProjectDnaProfile } from "./projectDna.js";
|
|
4
|
+
export const PROJECT_DNA_DELTA_SCHEMA_VERSION = "hunch.project-dna-delta/1";
|
|
5
|
+
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
6
|
+
const DELTA_ID = /^pdnad_[a-f0-9]{24}$/;
|
|
7
|
+
function canonical(value) {
|
|
8
|
+
if (Array.isArray(value))
|
|
9
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
10
|
+
if (value && typeof value === "object") {
|
|
11
|
+
return `{${Object.entries(value)
|
|
12
|
+
.filter(([, child]) => child !== undefined)
|
|
13
|
+
.sort(([left], [right]) => compareCodeUnits(left, right))
|
|
14
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
|
|
15
|
+
.join(",")}}`;
|
|
16
|
+
}
|
|
17
|
+
return JSON.stringify(value) ?? "null";
|
|
18
|
+
}
|
|
19
|
+
function sha256(value) {
|
|
20
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
21
|
+
}
|
|
22
|
+
function evidenceSeal(trait) {
|
|
23
|
+
return sha256(canonical(trait.evidence));
|
|
24
|
+
}
|
|
25
|
+
function mapTraits(profile) {
|
|
26
|
+
return new Map(profile.traits.map((trait) => [trait.key, trait]));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Compare two already-sealed profiles without inferring causality.
|
|
30
|
+
*
|
|
31
|
+
* A delta says only that observed DNA changed between exact revisions. It does
|
|
32
|
+
* not promote the new trait, explain why the change happened, or grant policy.
|
|
33
|
+
*/
|
|
34
|
+
export function diffProjectDna(fromValue, toValue) {
|
|
35
|
+
assertProjectDnaProfile(fromValue);
|
|
36
|
+
assertProjectDnaProfile(toValue);
|
|
37
|
+
const from = fromValue;
|
|
38
|
+
const to = toValue;
|
|
39
|
+
if (from.repository_id !== to.repository_id) {
|
|
40
|
+
throw new Error("project DNA profiles belong to different repositories");
|
|
41
|
+
}
|
|
42
|
+
const before = mapTraits(from);
|
|
43
|
+
const after = mapTraits(to);
|
|
44
|
+
const keys = [...new Set([...before.keys(), ...after.keys()])].sort(compareCodeUnits);
|
|
45
|
+
const changes = [];
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
const left = before.get(key);
|
|
48
|
+
const right = after.get(key);
|
|
49
|
+
if (!left && right) {
|
|
50
|
+
changes.push({
|
|
51
|
+
key,
|
|
52
|
+
kind: "added",
|
|
53
|
+
before_trait_id: null,
|
|
54
|
+
after_trait_id: right.id,
|
|
55
|
+
before_confidence: null,
|
|
56
|
+
after_confidence: right.confidence,
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (left && !right) {
|
|
61
|
+
changes.push({
|
|
62
|
+
key,
|
|
63
|
+
kind: "removed",
|
|
64
|
+
before_trait_id: left.id,
|
|
65
|
+
after_trait_id: null,
|
|
66
|
+
before_confidence: left.confidence,
|
|
67
|
+
after_confidence: null,
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!left || !right)
|
|
72
|
+
continue;
|
|
73
|
+
if (left.id !== right.id || evidenceSeal(left) !== evidenceSeal(right)) {
|
|
74
|
+
changes.push({
|
|
75
|
+
key,
|
|
76
|
+
kind: "evidence_changed",
|
|
77
|
+
before_trait_id: left.id,
|
|
78
|
+
after_trait_id: right.id,
|
|
79
|
+
before_confidence: left.confidence,
|
|
80
|
+
after_confidence: right.confidence,
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (left.confidence !== right.confidence) {
|
|
85
|
+
changes.push({
|
|
86
|
+
key,
|
|
87
|
+
kind: "confidence_changed",
|
|
88
|
+
before_trait_id: left.id,
|
|
89
|
+
after_trait_id: right.id,
|
|
90
|
+
before_confidence: left.confidence,
|
|
91
|
+
after_confidence: right.confidence,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const unsigned = {
|
|
96
|
+
schema: PROJECT_DNA_DELTA_SCHEMA_VERSION,
|
|
97
|
+
repository_id: from.repository_id,
|
|
98
|
+
from_profile_id: from.profile_id,
|
|
99
|
+
to_profile_id: to.profile_id,
|
|
100
|
+
from_revision: from.repository_revision,
|
|
101
|
+
to_revision: to.repository_revision,
|
|
102
|
+
changes,
|
|
103
|
+
changed: changes.length > 0,
|
|
104
|
+
};
|
|
105
|
+
const deltaId = `pdnad_${sha256(canonical(unsigned)).slice("sha256:".length, "sha256:".length + 24)}`;
|
|
106
|
+
const sealed = { ...unsigned, delta_id: deltaId };
|
|
107
|
+
const delta = { ...sealed, content_hash: sha256(canonical(sealed)) };
|
|
108
|
+
assertProjectDnaDelta(delta);
|
|
109
|
+
return delta;
|
|
110
|
+
}
|
|
111
|
+
export function assertProjectDnaDelta(value) {
|
|
112
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
113
|
+
throw new Error("project DNA delta is invalid");
|
|
114
|
+
const delta = value;
|
|
115
|
+
const expectedFields = [
|
|
116
|
+
"schema", "delta_id", "repository_id", "from_profile_id", "to_profile_id", "from_revision", "to_revision", "changes", "changed", "content_hash",
|
|
117
|
+
].sort(compareCodeUnits);
|
|
118
|
+
if (Object.keys(value).sort(compareCodeUnits).join("\0") !== expectedFields.join("\0")
|
|
119
|
+
|| delta.schema !== PROJECT_DNA_DELTA_SCHEMA_VERSION || !DELTA_ID.test(delta.delta_id)
|
|
120
|
+
|| !/^pdnar_[a-f0-9]{24}$/.test(delta.repository_id)
|
|
121
|
+
|| !/^pdna_[a-f0-9]{24}$/.test(delta.from_profile_id) || !/^pdna_[a-f0-9]{24}$/.test(delta.to_profile_id)
|
|
122
|
+
|| !/^[a-f0-9]{40,64}$/.test(delta.from_revision) || !/^[a-f0-9]{40,64}$/.test(delta.to_revision)
|
|
123
|
+
|| !Array.isArray(delta.changes) || delta.changes.length > 128 || delta.changed !== (delta.changes.length > 0)
|
|
124
|
+
|| !SHA256.test(delta.content_hash)) {
|
|
125
|
+
throw new Error("project DNA delta fields are invalid");
|
|
126
|
+
}
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
for (const change of delta.changes) {
|
|
129
|
+
if (!change || typeof change !== "object" || Array.isArray(change))
|
|
130
|
+
throw new Error("project DNA trait change is invalid");
|
|
131
|
+
const fields = ["key", "kind", "before_trait_id", "after_trait_id", "before_confidence", "after_confidence"].sort(compareCodeUnits);
|
|
132
|
+
if (Object.keys(change).sort(compareCodeUnits).join("\0") !== fields.join("\0")
|
|
133
|
+
|| !/^[a-z][a-z0-9_.-]{2,100}$/.test(change.key) || seen.has(change.key)
|
|
134
|
+
|| !["added", "removed", "evidence_changed", "confidence_changed"].includes(change.kind)
|
|
135
|
+
|| (change.before_trait_id !== null && !/^pdnat_[a-f0-9]{20}$/.test(change.before_trait_id))
|
|
136
|
+
|| (change.after_trait_id !== null && !/^pdnat_[a-f0-9]{20}$/.test(change.after_trait_id))
|
|
137
|
+
|| (change.before_confidence !== null && (!Number.isFinite(change.before_confidence) || change.before_confidence < 0 || change.before_confidence > 1))
|
|
138
|
+
|| (change.after_confidence !== null && (!Number.isFinite(change.after_confidence) || change.after_confidence < 0 || change.after_confidence > 1))) {
|
|
139
|
+
throw new Error("project DNA trait change fields are invalid");
|
|
140
|
+
}
|
|
141
|
+
if ((change.kind === "added" && (change.before_trait_id !== null || change.before_confidence !== null
|
|
142
|
+
|| change.after_trait_id === null || change.after_confidence === null))
|
|
143
|
+
|| (change.kind === "removed" && (change.before_trait_id === null || change.before_confidence === null
|
|
144
|
+
|| change.after_trait_id !== null || change.after_confidence !== null))
|
|
145
|
+
|| ((change.kind === "evidence_changed" || change.kind === "confidence_changed")
|
|
146
|
+
&& (change.before_trait_id === null || change.before_confidence === null
|
|
147
|
+
|| change.after_trait_id === null || change.after_confidence === null))) {
|
|
148
|
+
throw new Error("project DNA trait change transition is invalid");
|
|
149
|
+
}
|
|
150
|
+
seen.add(change.key);
|
|
151
|
+
}
|
|
152
|
+
const { content_hash: _contentHash, delta_id: _deltaId, ...base } = delta;
|
|
153
|
+
const expectedId = `pdnad_${sha256(canonical(base)).slice("sha256:".length, "sha256:".length + 24)}`;
|
|
154
|
+
const sealed = { ...base, delta_id: delta.delta_id };
|
|
155
|
+
if (delta.delta_id !== expectedId || delta.content_hash !== sha256(canonical(sealed))) {
|
|
156
|
+
throw new Error("project DNA delta seal is invalid");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=projectDnaDelta.js.map
|
package/dist/extractors/git.js
CHANGED
|
@@ -2125,6 +2125,53 @@ export function firstCommitForFile(file, cwd) {
|
|
|
2125
2125
|
export function lastChangeDate(file, cwd) {
|
|
2126
2126
|
return gitSafe(["log", "-1", "--format=%aI", "--", file], cwd);
|
|
2127
2127
|
}
|
|
2128
|
+
/**
|
|
2129
|
+
* Newest author date for every changed path selected by a bounded set of repository-relative
|
|
2130
|
+
* Hunch scopes. One Git history walk replaces the per-file process loop used by `staleness()`.
|
|
2131
|
+
*
|
|
2132
|
+
* The return keys are the concrete paths Git observed, not the input scopes: callers can apply
|
|
2133
|
+
* Hunch's own exact/glob/directory matcher without treating Git pathspec interpretation as graph
|
|
2134
|
+
* authority. A failed or oversized read returns null, so freshness stays unknown rather than
|
|
2135
|
+
* partially scoring a record.
|
|
2136
|
+
*/
|
|
2137
|
+
export function scopedLastChangeDates(scopes, cwd, maxChangedPaths = 4_096) {
|
|
2138
|
+
const pathspecs = [...new Set(scopes)].map((scope) => {
|
|
2139
|
+
const normalized = scope.replaceAll("\\", "/");
|
|
2140
|
+
return /[*?[]/.test(normalized) ? `:(glob)${normalized}` : `:(literal)${normalized}`;
|
|
2141
|
+
});
|
|
2142
|
+
if (pathspecs.length === 0)
|
|
2143
|
+
return new Map();
|
|
2144
|
+
const raw = gitRawSafeIsolated([
|
|
2145
|
+
"-c", "core.quotePath=false",
|
|
2146
|
+
"log", "-z", "--name-only", "--format=HUNCH_DATE:%aI%x00", "--", ...pathspecs,
|
|
2147
|
+
], cwd, 64 * 1024 * 1024);
|
|
2148
|
+
if (raw === null)
|
|
2149
|
+
return null;
|
|
2150
|
+
const out = new Map();
|
|
2151
|
+
let commitDate = "";
|
|
2152
|
+
for (const rawToken of raw.split("\0")) {
|
|
2153
|
+
// Git inserts one presentation newline between a custom commit format and its
|
|
2154
|
+
// first NUL-delimited path. Remove exactly that byte; a real leading newline in
|
|
2155
|
+
// a path remains as the second byte and therefore cannot alias a normal path.
|
|
2156
|
+
const token = rawToken.startsWith("\n") ? rawToken.slice(1) : rawToken;
|
|
2157
|
+
if (!token)
|
|
2158
|
+
continue;
|
|
2159
|
+
if (token.startsWith("HUNCH_DATE:")) {
|
|
2160
|
+
const candidate = token.slice("HUNCH_DATE:".length);
|
|
2161
|
+
commitDate = Number.isFinite(Date.parse(candidate)) ? candidate : "";
|
|
2162
|
+
continue;
|
|
2163
|
+
}
|
|
2164
|
+
if (!commitDate)
|
|
2165
|
+
continue;
|
|
2166
|
+
const previous = out.get(token);
|
|
2167
|
+
if (!previous || Date.parse(commitDate) > Date.parse(previous)) {
|
|
2168
|
+
out.set(token, commitDate);
|
|
2169
|
+
if (out.size > maxChangedPaths)
|
|
2170
|
+
return null;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
return out;
|
|
2174
|
+
}
|
|
2128
2175
|
/** Batched per-file git metrics for indexing: churn (commits touching the file in
|
|
2129
2176
|
* the last `days`; pass 0 to skip) and the most-recent commit (`commit:<sha>`).
|
|
2130
2177
|
*
|
package/dist/mcp/server.js
CHANGED
|
@@ -27,6 +27,9 @@ import { compileVerifiedEvidenceMap, EvidenceExecutionSchema, EvidenceInterventi
|
|
|
27
27
|
import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
|
|
28
28
|
import { buildDeliveryEnvelope, DELIVERY_PROFILE_POLICY_VERSION, DELIVERY_PROFILES, } from "../core/delivery.js";
|
|
29
29
|
import { CHANGE_IDENTITY_ALGORITHM, CHANGE_IDENTITY_SCHEMA_VERSION, deriveChangeIdentity, } from "../core/changeIdentity.js";
|
|
30
|
+
import { PROJECT_DNA_CATEGORIES, PROJECT_DNA_MATCH_SCHEMA_VERSION, PROJECT_DNA_SCHEMA_VERSION, discoverProjectDna, evaluateProjectDnaMatch, } from "../core/projectDna.js";
|
|
31
|
+
import { PROJECT_DNA_DELTA_SCHEMA_VERSION, diffProjectDna } from "../core/projectDnaDelta.js";
|
|
32
|
+
import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
|
|
30
33
|
import { armExecutionObligations, loadPipelineState, savePipelineState } from "../core/pipeline.js";
|
|
31
34
|
import { recordServed } from "../core/served.js";
|
|
32
35
|
import { EdgeSchema, ResourceSchema } from "../core/types.js";
|
|
@@ -250,6 +253,73 @@ const CHANGE_IDENTITY_OUTPUT_SCHEMA = z.object({
|
|
|
250
253
|
paths_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
251
254
|
content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
252
255
|
});
|
|
256
|
+
const PROJECT_DNA_EVIDENCE_SCHEMA = z.object({
|
|
257
|
+
kind: z.enum(["git-history", "committed-file"]),
|
|
258
|
+
ref: z.string(),
|
|
259
|
+
revision: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
260
|
+
content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
261
|
+
sample_count: z.number().int().positive(),
|
|
262
|
+
provenance: z.literal("committed-repository"),
|
|
263
|
+
visibility: z.literal("repository"),
|
|
264
|
+
});
|
|
265
|
+
const PROJECT_DNA_PROFILE_OUTPUT_SCHEMA = z.object({
|
|
266
|
+
schema: z.literal(PROJECT_DNA_SCHEMA_VERSION),
|
|
267
|
+
profile_id: z.string().regex(/^pdna_[a-f0-9]{24}$/),
|
|
268
|
+
repository_id: z.string().regex(/^pdnar_[a-f0-9]{24}$/),
|
|
269
|
+
repository_revision: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
270
|
+
history_sample_count: z.number().int().nonnegative(),
|
|
271
|
+
source_files: z.array(z.string()),
|
|
272
|
+
traits: z.array(z.object({
|
|
273
|
+
id: z.string().regex(/^pdnat_[a-f0-9]{20}$/),
|
|
274
|
+
category: z.enum(PROJECT_DNA_CATEGORIES),
|
|
275
|
+
key: z.string(),
|
|
276
|
+
claim: z.string(),
|
|
277
|
+
confidence: z.number().min(0).max(1),
|
|
278
|
+
observation_state: z.literal("observed"),
|
|
279
|
+
freshness: z.literal("current"),
|
|
280
|
+
contradiction: z.literal("none"),
|
|
281
|
+
evidence: z.array(PROJECT_DNA_EVIDENCE_SCHEMA),
|
|
282
|
+
})),
|
|
283
|
+
content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
284
|
+
});
|
|
285
|
+
const PROJECT_DNA_MATCH_OUTPUT_SCHEMA = z.object({
|
|
286
|
+
schema: z.literal(PROJECT_DNA_MATCH_SCHEMA_VERSION),
|
|
287
|
+
match_id: z.string().regex(/^pdnam_[a-f0-9]{24}$/),
|
|
288
|
+
profile_id: z.string().regex(/^pdna_[a-f0-9]{24}$/),
|
|
289
|
+
repository_id: z.string().regex(/^pdnar_[a-f0-9]{24}$/),
|
|
290
|
+
repository_revision: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
291
|
+
artifact_kind: z.enum(["commit", "pull_request", "issue", "message"]),
|
|
292
|
+
score: z.number().min(0).max(100).nullable(),
|
|
293
|
+
applicable_checks: z.number().int().nonnegative(),
|
|
294
|
+
checks: z.array(z.object({
|
|
295
|
+
trait_id: z.string().regex(/^pdnat_[a-f0-9]{20}$/),
|
|
296
|
+
key: z.string(),
|
|
297
|
+
applicable: z.boolean(),
|
|
298
|
+
passed: z.boolean().nullable(),
|
|
299
|
+
weight: z.number().int().positive(),
|
|
300
|
+
detail: z.string(),
|
|
301
|
+
})),
|
|
302
|
+
content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
303
|
+
});
|
|
304
|
+
const PROJECT_DNA_DELTA_OUTPUT_SCHEMA = z.object({
|
|
305
|
+
schema: z.literal(PROJECT_DNA_DELTA_SCHEMA_VERSION),
|
|
306
|
+
delta_id: z.string().regex(/^pdnad_[a-f0-9]{24}$/),
|
|
307
|
+
repository_id: z.string().regex(/^pdnar_[a-f0-9]{24}$/),
|
|
308
|
+
from_profile_id: z.string().regex(/^pdna_[a-f0-9]{24}$/),
|
|
309
|
+
to_profile_id: z.string().regex(/^pdna_[a-f0-9]{24}$/),
|
|
310
|
+
from_revision: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
311
|
+
to_revision: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
312
|
+
changes: z.array(z.object({
|
|
313
|
+
key: z.string(),
|
|
314
|
+
kind: z.enum(["added", "removed", "evidence_changed", "confidence_changed"]),
|
|
315
|
+
before_trait_id: z.string().regex(/^pdnat_[a-f0-9]{20}$/).nullable(),
|
|
316
|
+
after_trait_id: z.string().regex(/^pdnat_[a-f0-9]{20}$/).nullable(),
|
|
317
|
+
before_confidence: z.number().min(0).max(1).nullable(),
|
|
318
|
+
after_confidence: z.number().min(0).max(1).nullable(),
|
|
319
|
+
})),
|
|
320
|
+
changed: z.boolean(),
|
|
321
|
+
content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
322
|
+
});
|
|
253
323
|
/** Return the same human-readable brief older clients consume plus the exact
|
|
254
324
|
* machine-readable envelope. Receipt recording is deliberately best-effort:
|
|
255
325
|
* recordServed never throws, so telemetry can never cost a delivery. */
|
|
@@ -835,6 +905,76 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
835
905
|
return err(error.message);
|
|
836
906
|
}
|
|
837
907
|
});
|
|
908
|
+
// -- hunch_project_dna ----------------------------------------------------
|
|
909
|
+
server.registerTool("hunch_project_dna", {
|
|
910
|
+
title: "Inspect this repository's evidence-backed Project DNA",
|
|
911
|
+
description: "Derive a bounded, deterministic profile of how this repository communicates, reviews, and builds from one exact committed revision. Read-only: observations remain advisory and are never adopted into graph authority automatically.",
|
|
912
|
+
inputSchema: {
|
|
913
|
+
ref: z.string().min(1).max(1_024).optional().describe("Git commit/ref to inspect (default HEAD)."),
|
|
914
|
+
cwd: cwdHintField,
|
|
915
|
+
},
|
|
916
|
+
outputSchema: PROJECT_DNA_PROFILE_OUTPUT_SCHEMA,
|
|
917
|
+
}, async ({ ref }) => {
|
|
918
|
+
try {
|
|
919
|
+
const profile = PROJECT_DNA_PROFILE_OUTPUT_SCHEMA.parse(discoverProjectDna(root, ref ?? "HEAD"));
|
|
920
|
+
const headline = `${profile.profile_id} at ${profile.repository_revision}: ${profile.traits.length} evidence-backed trait(s) from ${profile.history_sample_count} commit subject(s) and ${profile.source_files.length} convention file(s). Advisory observation only.`;
|
|
921
|
+
return { content: [{ type: "text", text: headline }], structuredContent: profile };
|
|
922
|
+
}
|
|
923
|
+
catch (error) {
|
|
924
|
+
return err(error.message);
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
// -- hunch_project_match --------------------------------------------------
|
|
928
|
+
server.registerTool("hunch_project_match", {
|
|
929
|
+
title: "Evaluate whether an artifact matches this repository's Project DNA",
|
|
930
|
+
description: "Explainably score a commit subject, PR, issue, or message using only deterministic checks supported by an exact-revision Project DNA profile. Advisory: never changes policy or enforcement authority.",
|
|
931
|
+
inputSchema: {
|
|
932
|
+
kind: z.enum(["commit", "pull_request", "issue", "message"]),
|
|
933
|
+
title: z.string().min(1).max(1_000),
|
|
934
|
+
body: z.string().max(20_000).optional(),
|
|
935
|
+
ref: z.string().min(1).max(1_024).optional().describe("Git commit/ref whose DNA should be used (default HEAD)."),
|
|
936
|
+
cwd: cwdHintField,
|
|
937
|
+
},
|
|
938
|
+
outputSchema: PROJECT_DNA_MATCH_OUTPUT_SCHEMA,
|
|
939
|
+
}, async ({ kind, title, body, ref }) => {
|
|
940
|
+
try {
|
|
941
|
+
const profile = discoverProjectDna(root, ref ?? "HEAD");
|
|
942
|
+
const match = PROJECT_DNA_MATCH_OUTPUT_SCHEMA.parse(evaluateProjectDnaMatch(profile, { kind, title, body }));
|
|
943
|
+
const failed = match.checks.filter((check) => check.applicable && !check.passed).map((check) => check.key);
|
|
944
|
+
const headline = match.score === null
|
|
945
|
+
? `${match.match_id}: no deterministic DNA checks apply to this artifact.`
|
|
946
|
+
: `${match.match_id}: Project Match ${match.score.toFixed(1)}/100 across ${match.applicable_checks} check(s)${failed.length ? `; mismatches: ${failed.join(", ")}` : ""}. Advisory only.`;
|
|
947
|
+
return { content: [{ type: "text", text: headline }], structuredContent: match };
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
return err(error.message);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
// -- hunch_project_dna_delta ---------------------------------------------
|
|
954
|
+
server.registerTool("hunch_project_dna_delta", {
|
|
955
|
+
title: "Compare Project DNA across two exact repository revisions",
|
|
956
|
+
description: "Return a sealed, explainable delta between two immutable Project DNA profiles. Read-only: reports observation drift and never rewrites history or graph authority.",
|
|
957
|
+
inputSchema: {
|
|
958
|
+
from_ref: z.string().min(1).max(1_024).describe("Older Git commit/ref."),
|
|
959
|
+
to_ref: z.string().min(1).max(1_024).describe("Newer Git commit/ref."),
|
|
960
|
+
cwd: cwdHintField,
|
|
961
|
+
},
|
|
962
|
+
outputSchema: PROJECT_DNA_DELTA_OUTPUT_SCHEMA,
|
|
963
|
+
}, async ({ from_ref, to_ref }) => {
|
|
964
|
+
try {
|
|
965
|
+
const delta = PROJECT_DNA_DELTA_OUTPUT_SCHEMA.parse(diffProjectDna(discoverProjectDna(root, from_ref), discoverProjectDna(root, to_ref)));
|
|
966
|
+
return {
|
|
967
|
+
content: [{
|
|
968
|
+
type: "text",
|
|
969
|
+
text: `${delta.delta_id}: ${delta.changed ? `${delta.changes.length} observed DNA change(s)` : "no observed DNA change"} from ${delta.from_revision} to ${delta.to_revision}. Advisory only.`,
|
|
970
|
+
}],
|
|
971
|
+
structuredContent: delta,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
catch (error) {
|
|
975
|
+
return err(error.message);
|
|
976
|
+
}
|
|
977
|
+
});
|
|
838
978
|
// -- hunch_context (surgical retrieval) -----------------------------------
|
|
839
979
|
server.registerTool("hunch_context", {
|
|
840
980
|
title: "Assemble the minimal relevant Hunch slice for a task",
|
|
@@ -851,6 +991,15 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
851
991
|
if (as_of && !asOf)
|
|
852
992
|
return err(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
853
993
|
const ctx = store.assembleContext(target, budget_tokens ?? 1500, { asOf });
|
|
994
|
+
let dnaSupplement = null;
|
|
995
|
+
try {
|
|
996
|
+
dnaSupplement = projectDnaDeliverySupplement(discoverProjectDna(root, as_of ?? "HEAD"));
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
// Context retrieval must keep its existing graceful behavior when the
|
|
1000
|
+
// Git checkout cannot provide DNA; the dedicated DNA tool reports the
|
|
1001
|
+
// exact derivation error when a caller needs diagnostics.
|
|
1002
|
+
}
|
|
854
1003
|
const options = {
|
|
855
1004
|
root,
|
|
856
1005
|
symbols: store.recs("symbols"),
|
|
@@ -858,6 +1007,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
858
1007
|
decisionCorpus: store.recs("decisions"),
|
|
859
1008
|
historical: !!asOf,
|
|
860
1009
|
profile: profile ?? "builder",
|
|
1010
|
+
supplements: dnaSupplement ? [dnaSupplement] : [],
|
|
861
1011
|
};
|
|
862
1012
|
// Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
|
|
863
1013
|
// used to return an empty brief while the graph held the answer — fall back to
|
|
@@ -870,7 +1020,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
870
1020
|
!ctx.landscape?.resources.length &&
|
|
871
1021
|
!ctx.landscape?.relationships.length;
|
|
872
1022
|
if (empty && !asOf) {
|
|
873
|
-
const hits = store.
|
|
1023
|
+
const hits = store.rankedSearch(target, 8);
|
|
874
1024
|
if (hits.length) {
|
|
875
1025
|
const resolved = hits.map((hit) => ({ hit, record: store.resolve(hit.ref)?.record }));
|
|
876
1026
|
const fallback = {
|
|
@@ -882,14 +1032,17 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
882
1032
|
};
|
|
883
1033
|
const envelope = buildDeliveryEnvelope(fallback, {
|
|
884
1034
|
...options,
|
|
885
|
-
supplements:
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1035
|
+
supplements: [
|
|
1036
|
+
...(dnaSupplement ? [dnaSupplement] : []),
|
|
1037
|
+
...hits
|
|
1038
|
+
.filter((hit) => !["constraints", "decisions", "bugs", "findings"].includes(hit.kind))
|
|
1039
|
+
.map((hit, index) => ({
|
|
1040
|
+
id: hit.ref,
|
|
1041
|
+
kind: `search-${hit.kind}`,
|
|
1042
|
+
text: `${hit.ref} — ${hit.title}: ${hit.snippet}`,
|
|
1043
|
+
priority: 100 - index,
|
|
1044
|
+
})),
|
|
1045
|
+
],
|
|
893
1046
|
});
|
|
894
1047
|
return deliveredContext(root, target, envelope, extra.sessionId);
|
|
895
1048
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable public programmatic surface for the Project DNA Engine.
|
|
3
|
+
*
|
|
4
|
+
* Keep transport/orchestration consumers on this barrel so internal core file
|
|
5
|
+
* layout can evolve without changing the published contract entry point.
|
|
6
|
+
*/
|
|
7
|
+
export { PROJECT_DNA_CATEGORIES, PROJECT_DNA_MATCH_SCHEMA_VERSION, PROJECT_DNA_SCHEMA_VERSION, assertProjectDnaMatch, assertProjectDnaProfile, discoverProjectDna, evaluateProjectDnaMatch, } from "./core/projectDna.js";
|
|
8
|
+
export { PROJECT_DNA_DELTA_SCHEMA_VERSION, assertProjectDnaDelta, diffProjectDna, } from "./core/projectDnaDelta.js";
|
|
9
|
+
export { PROJECT_DNA_SUPPLEMENT_KIND, projectDnaDeliverySupplement, } from "./core/projectDnaDelivery.js";
|
|
10
|
+
//# sourceMappingURL=projectDna.js.map
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -18,7 +18,7 @@ import { openDb, withTx } from "./db.js";
|
|
|
18
18
|
import { RESET_SQL, embedHash } from "./schema.js";
|
|
19
19
|
import { selectEmbedder } from "./embedder.js";
|
|
20
20
|
import { JsonStore } from "./jsonStore.js";
|
|
21
|
-
import { gitCommonDir, gitWorktreeRoot, sameGitPublication } from "../extractors/git.js";
|
|
21
|
+
import { gitCommonDir, gitWorktreeRoot, isolatedHeadSha, sameGitPublication, scopedLastChangeDates, } from "../extractors/git.js";
|
|
22
22
|
import { pathMatchesGlob, pathsRelated } from "../core/glob.js";
|
|
23
23
|
import { currentForTopic, isInForce } from "../core/topics.js";
|
|
24
24
|
import { edgeId } from "../core/ids.js";
|
|
@@ -85,6 +85,11 @@ export class HunchStore {
|
|
|
85
85
|
* equal to a committed file (dec_d7bad4ccb7). */
|
|
86
86
|
suppressPrivate = false;
|
|
87
87
|
_db = null;
|
|
88
|
+
/** HEAD-keyed, process-local ranking evidence. It never changes record authority or storage. */
|
|
89
|
+
decisionFreshnessRoot = "";
|
|
90
|
+
decisionFreshnessHead = "";
|
|
91
|
+
decisionFreshnessScopes = new Set();
|
|
92
|
+
decisionFreshnessChanges = new Map();
|
|
88
93
|
constructor(paths) {
|
|
89
94
|
this.paths = paths;
|
|
90
95
|
this.json = new JsonStore(paths);
|
|
@@ -621,7 +626,8 @@ export class HunchStore {
|
|
|
621
626
|
/** Post-fusion rerank by graph PRIORS (dec_25e277f479): relevance ordering, not
|
|
622
627
|
* just reachability. Trust weight w = liveness × provenance × recency: liveness 0.6
|
|
623
628
|
* for superseded/retired/rejected, provenance 1.0 / 0.85 / 0.75 for
|
|
624
|
-
* human_confirmed / llm_draft / extracted-inferred, recency 0.7 + 0.3·½^(age/90d)
|
|
629
|
+
* human_confirmed / llm_draft / extracted-inferred, recency 0.7 + 0.3·½^(age/90d),
|
|
630
|
+
* and proven anchored-file staleness 0.8. Every factor is ranking-only.
|
|
625
631
|
* Runbook trigger phrases matching the query boost ×1.5 (exact intent beats
|
|
626
632
|
* keyword luck). Structural refs (symbols/components/edges) stay neutral.
|
|
627
633
|
*
|
|
@@ -643,7 +649,7 @@ export class HunchStore {
|
|
|
643
649
|
* a stale or low-provenance record visibly dims, an exact runbook-trigger match
|
|
644
650
|
* visibly promotes, and neither can leapfrog the whole pool. Same measurement
|
|
645
651
|
* after: 0% evicted from fused ranks 0–8. Deterministic; ties keep fused order. */
|
|
646
|
-
rerankByPriors(hits, limit, query) {
|
|
652
|
+
rerankByPriors(hits, limit, query, freshnessRoot = this.paths.root) {
|
|
647
653
|
if (!hits.length)
|
|
648
654
|
return hits; // a SINGLE hit still runs — topic-chain promotion must fire for the lone stale match
|
|
649
655
|
const now = Date.now();
|
|
@@ -672,6 +678,10 @@ export class HunchStore {
|
|
|
672
678
|
pos: i + 0.5,
|
|
673
679
|
});
|
|
674
680
|
}
|
|
681
|
+
const decisionsById = new Map(this.recs("decisions").map((decision) => [decision.id, decision]));
|
|
682
|
+
const staleDecisionIds = this.staleDecisionIds(pool.filter(({ h }) => h.kind === "decisions")
|
|
683
|
+
.map(({ h }) => decisionsById.get(h.ref))
|
|
684
|
+
.filter((decision) => !!decision), freshnessRoot);
|
|
675
685
|
const scored = pool.map(({ h, pos }) => {
|
|
676
686
|
const m = this.priorMeta(h.ref, h.kind);
|
|
677
687
|
let w = 1;
|
|
@@ -694,6 +704,10 @@ export class HunchStore {
|
|
|
694
704
|
if (Number.isFinite(ageDays))
|
|
695
705
|
w *= 0.7 + 0.3 * Math.pow(0.5, ageDays / 90);
|
|
696
706
|
}
|
|
707
|
+
// File-change staleness is a bounded relevance signal only. It cannot retire,
|
|
708
|
+
// withhold, supersede, or weaken a decision's enforcement authority.
|
|
709
|
+
if (h.kind === "decisions" && staleDecisionIds.has(h.ref))
|
|
710
|
+
w *= STALE_DECISION_PRIOR_WEIGHT;
|
|
697
711
|
if (q && m.triggers?.some((tr) => q.includes(tr) || tr.includes(q)))
|
|
698
712
|
w *= 1.5;
|
|
699
713
|
}
|
|
@@ -708,6 +722,77 @@ export class HunchStore {
|
|
|
708
722
|
scored.sort((a, b) => a.pos - b.pos);
|
|
709
723
|
return scored.slice(0, limit).map((x) => x.h);
|
|
710
724
|
}
|
|
725
|
+
/**
|
|
726
|
+
* Score only freshness that the existing graph clocks can prove: an anchored path changed after
|
|
727
|
+
* `provenance.last_verified`. One bounded Git pass fills a HEAD-keyed cache for newly encountered
|
|
728
|
+
* scopes; repeated MCP/CLI queries perform no history walk until HEAD changes.
|
|
729
|
+
*/
|
|
730
|
+
staleDecisionIds(decisions, freshnessRoot) {
|
|
731
|
+
const root = resolve(freshnessRoot);
|
|
732
|
+
const head = isolatedHeadSha(root);
|
|
733
|
+
if (!head)
|
|
734
|
+
return new Set();
|
|
735
|
+
if (root !== this.decisionFreshnessRoot || head !== this.decisionFreshnessHead) {
|
|
736
|
+
this.decisionFreshnessRoot = root;
|
|
737
|
+
this.decisionFreshnessHead = head;
|
|
738
|
+
this.decisionFreshnessScopes.clear();
|
|
739
|
+
this.decisionFreshnessChanges.clear();
|
|
740
|
+
}
|
|
741
|
+
const eligible = new Map();
|
|
742
|
+
let scopeCount = 0;
|
|
743
|
+
for (const decision of decisions) {
|
|
744
|
+
const verifiedAt = Date.parse(decision.provenance.last_verified ?? "");
|
|
745
|
+
if (!Number.isFinite(verifiedAt))
|
|
746
|
+
continue;
|
|
747
|
+
const scopes = [...new Set(decision.related_files.map(safeFreshnessScope).filter(Boolean))];
|
|
748
|
+
if (scopes.length === 0 || scopeCount + scopes.length > DECISION_FRESHNESS_SCOPE_QUERY_CAP)
|
|
749
|
+
continue;
|
|
750
|
+
scopeCount += scopes.length;
|
|
751
|
+
eligible.set(decision.id, { verifiedAt, scopes });
|
|
752
|
+
}
|
|
753
|
+
let missing = [...new Set([...eligible.values()].flatMap((candidate) => candidate.scopes))]
|
|
754
|
+
.filter((scope) => !this.decisionFreshnessScopes.has(scope));
|
|
755
|
+
if (missing.length) {
|
|
756
|
+
if (this.decisionFreshnessScopes.size + missing.length > DECISION_FRESHNESS_SCOPE_CACHE_CAP) {
|
|
757
|
+
this.decisionFreshnessScopes.clear();
|
|
758
|
+
this.decisionFreshnessChanges.clear();
|
|
759
|
+
missing = [...new Set([...eligible.values()].flatMap((candidate) => candidate.scopes))];
|
|
760
|
+
}
|
|
761
|
+
const observed = scopedLastChangeDates(missing, root, DECISION_FRESHNESS_PATH_CACHE_CAP);
|
|
762
|
+
if (observed) {
|
|
763
|
+
if (this.decisionFreshnessChanges.size + observed.size > DECISION_FRESHNESS_PATH_CACHE_CAP) {
|
|
764
|
+
this.decisionFreshnessScopes.clear();
|
|
765
|
+
this.decisionFreshnessChanges.clear();
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
missing.forEach((scope) => this.decisionFreshnessScopes.add(scope));
|
|
769
|
+
observed.forEach((date, path) => {
|
|
770
|
+
const previous = this.decisionFreshnessChanges.get(path);
|
|
771
|
+
if (!previous || Date.parse(date) > Date.parse(previous))
|
|
772
|
+
this.decisionFreshnessChanges.set(path, date);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const stale = new Set();
|
|
778
|
+
for (const [decisionId, candidate] of eligible) {
|
|
779
|
+
if (candidate.scopes.some((scope) => !this.decisionFreshnessScopes.has(scope)))
|
|
780
|
+
continue;
|
|
781
|
+
for (const [changedPath, changedAt] of this.decisionFreshnessChanges) {
|
|
782
|
+
if (Date.parse(changedAt) <= candidate.verifiedAt)
|
|
783
|
+
continue;
|
|
784
|
+
if (candidate.scopes.some((scope) => freshnessScopeMatches(changedPath, scope))) {
|
|
785
|
+
stale.add(decisionId);
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return stale;
|
|
791
|
+
}
|
|
792
|
+
/** Fast relevance ranking for task-phrase context fallback: FTS + bounded graph priors, no model. */
|
|
793
|
+
rankedSearch(query, limit = 12, opts = {}) {
|
|
794
|
+
return this.rerankByPriors(this.search(query, Math.max(limit, 24)), limit, query, opts.freshnessRoot ?? this.paths.root);
|
|
795
|
+
}
|
|
711
796
|
/** The prior-bearing metadata for a hit: liveness, provenance, effective date,
|
|
712
797
|
* and (runbooks) trigger phrases. null = structural ref, neutral prior. */
|
|
713
798
|
priorMeta(ref, kind) {
|
|
@@ -779,9 +864,9 @@ export class HunchStore {
|
|
|
779
864
|
tokenCap: boundedWhole(opts.graphTokenCap, GRAPH_TOKEN_CAP, GRAPH_TOKEN_HARD_MAX),
|
|
780
865
|
}, gw);
|
|
781
866
|
if (!sem.length && !graph.length)
|
|
782
|
-
return this.rerankByPriors(fts, limit, query);
|
|
867
|
+
return this.rerankByPriors(fts, limit, query, opts.freshnessRoot ?? this.paths.root);
|
|
783
868
|
// Fuse with headroom so the prior rerank can promote from below the cut line.
|
|
784
|
-
return this.rerankByPriors(this.rrfFuse(fts, sem, graph, Math.max(limit, 24), gw), limit, query);
|
|
869
|
+
return this.rerankByPriors(this.rrfFuse(fts, sem, graph, Math.max(limit, 24), gw), limit, query, opts.freshnessRoot ?? this.paths.root);
|
|
785
870
|
}
|
|
786
871
|
/** Runbook-scoped retrieval (roadmap #5): the same FTS+graph(+semantic) fusion,
|
|
787
872
|
* restricted to the `runbooks` kind — so a "what's the procedure for X" query
|
|
@@ -1777,6 +1862,11 @@ const GRAPH_TOKEN_CAP = boundedWhole(numEnv("HUNCH_GRAPH_TOKEN_CAP", 2_000), 2_0
|
|
|
1777
1862
|
* rerankByPriors for the measurement that fixed it at 4. */
|
|
1778
1863
|
const PRIOR_SHIFT_SCALE = numEnv("HUNCH_PRIOR_SHIFT_SCALE", 12);
|
|
1779
1864
|
const MAX_PRIOR_SHIFT = numEnv("HUNCH_MAX_PRIOR_SHIFT", 4);
|
|
1865
|
+
/** A stale anchored decision stays visible and authoritative; it moves down by at most the shared prior clamp. */
|
|
1866
|
+
const STALE_DECISION_PRIOR_WEIGHT = Math.min(1, numEnv("HUNCH_STALE_DECISION_PRIOR_WEIGHT", 0.8));
|
|
1867
|
+
const DECISION_FRESHNESS_SCOPE_QUERY_CAP = 256;
|
|
1868
|
+
const DECISION_FRESHNESS_SCOPE_CACHE_CAP = 512;
|
|
1869
|
+
const DECISION_FRESHNESS_PATH_CACHE_CAP = 4_096;
|
|
1780
1870
|
/** Memory-record prior: a "why" question is answered by RECORDED INTENT (decisions,
|
|
1781
1871
|
* constraints, bugs, runbooks, policies), not by the code symbols that merely share
|
|
1782
1872
|
* its vocabulary. Symbols carry a neutral prior (priorMeta -> null), so on a graph
|
|
@@ -1789,6 +1879,23 @@ const MAX_PRIOR_SHIFT = numEnv("HUNCH_MAX_PRIOR_SHIFT", 4);
|
|
|
1789
1879
|
* 0.402 -> 0.575. Set HUNCH_MEMORY_PRIOR_SHIFT=0 to disable. */
|
|
1790
1880
|
const MEMORY_PRIOR_SHIFT = numEnv("HUNCH_MEMORY_PRIOR_SHIFT", 12);
|
|
1791
1881
|
const MEMORY_KINDS = new Set(["decisions", "constraints", "bugs", "runbooks", "policies"]);
|
|
1882
|
+
function safeFreshnessScope(value) {
|
|
1883
|
+
const normalized = toPosixTarget(value.trim());
|
|
1884
|
+
if (!normalized || normalized.length > 1_024 || normalized.includes("\0")
|
|
1885
|
+
|| isAbsolute(normalized) || /^[a-zA-Z]:/.test(normalized)
|
|
1886
|
+
|| normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")
|
|
1887
|
+
|| normalized.startsWith("private:"))
|
|
1888
|
+
return null;
|
|
1889
|
+
return normalized.replace(/^\.\//, "");
|
|
1890
|
+
}
|
|
1891
|
+
function freshnessScopeMatches(path, scope) {
|
|
1892
|
+
try {
|
|
1893
|
+
return pathsRelated(path, scope) || pathMatchesGlob(path, scope);
|
|
1894
|
+
}
|
|
1895
|
+
catch {
|
|
1896
|
+
return false;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1792
1899
|
function numEnv(name, dflt) {
|
|
1793
1900
|
const v = Number(process.env[name]);
|
|
1794
1901
|
// >= 0, not > 0: zero is the documented kill-switch (HUNCH_RRF_W_*=0 disables
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
"bin": {
|
|
18
18
|
"hunch": "dist/cli/index.js"
|
|
19
19
|
},
|
|
20
|
+
"exports": {
|
|
21
|
+
"./project-dna": {
|
|
22
|
+
"types": "./dist/projectDna.d.ts",
|
|
23
|
+
"default": "./dist/projectDna.js"
|
|
24
|
+
},
|
|
25
|
+
"./dist/*": "./dist/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
20
28
|
"files": [
|
|
21
29
|
"dist/**/*.js",
|
|
22
30
|
"server.json",
|