@davesheffer/hunch 1.20.3 → 1.21.1

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.
@@ -0,0 +1,478 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { compareCodeUnits } from "./canonicalOrder.js";
4
+ export const PROJECT_DNA_SCHEMA_VERSION = "hunch.project-dna/1";
5
+ export const PROJECT_DNA_MATCH_SCHEMA_VERSION = "hunch.project-dna-match/1";
6
+ const GIT_OBJECT = /^[a-f0-9]{40,64}$/;
7
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
8
+ const PROFILE_ID = /^pdna_[a-f0-9]{24}$/;
9
+ const REPOSITORY_ID = /^pdnar_[a-f0-9]{24}$/;
10
+ const MATCH_ID = /^pdnam_[a-f0-9]{24}$/;
11
+ const MAX_GIT_OUTPUT = 8 * 1024 * 1024;
12
+ const MAX_HISTORY = 200;
13
+ const MIN_HISTORY = 5;
14
+ const MAX_EVIDENCE = 8;
15
+ const MAX_TRAITS = 64;
16
+ const MAX_FILE_BYTES = 256 * 1024;
17
+ export const PROJECT_DNA_CATEGORIES = ["communication", "engineering", "review", "culture", "vocabulary"];
18
+ const STOP_WORDS = new Set([
19
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "into", "is", "it", "of", "on",
20
+ "or", "that", "the", "this", "to", "with", "without", "add", "adds", "added", "fix", "fixes", "fixed",
21
+ "update", "updates", "updated", "change", "changes", "changed", "remove", "removes", "removed", "merge",
22
+ ]);
23
+ const SOURCE_FILES = [
24
+ "CONTRIBUTING.md",
25
+ ".github/CONTRIBUTING.md",
26
+ ".github/PULL_REQUEST_TEMPLATE.md",
27
+ ".github/pull_request_template.md",
28
+ "PULL_REQUEST_TEMPLATE.md",
29
+ "AGENTS.md",
30
+ "CLAUDE.md",
31
+ ];
32
+ function canonical(value) {
33
+ if (Array.isArray(value))
34
+ return `[${value.map(canonical).join(",")}]`;
35
+ if (value && typeof value === "object") {
36
+ return `{${Object.entries(value)
37
+ .filter(([, child]) => child !== undefined)
38
+ .sort(([left], [right]) => compareCodeUnits(left, right))
39
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
40
+ .join(",")}}`;
41
+ }
42
+ return JSON.stringify(value) ?? "null";
43
+ }
44
+ function sha256(value) {
45
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
46
+ }
47
+ function gitEnvironment() {
48
+ const environment = { ...process.env, GIT_NO_REPLACE_OBJECTS: "1", LC_ALL: "C", LANG: "C" };
49
+ for (const name of [
50
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
51
+ "GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
52
+ "GIT_INDEX_FILE", "GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_INTERNAL_SUPER_PREFIX",
53
+ "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
54
+ ])
55
+ delete environment[name];
56
+ for (const name of Object.keys(environment)) {
57
+ if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(name))
58
+ delete environment[name];
59
+ }
60
+ return environment;
61
+ }
62
+ function gitBytes(root, args, maxBuffer = MAX_GIT_OUTPUT) {
63
+ try {
64
+ return execFileSync("git", ["-C", root, ...args], {
65
+ encoding: "buffer",
66
+ env: gitEnvironment(),
67
+ maxBuffer,
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ timeout: 15_000,
70
+ });
71
+ }
72
+ catch (error) {
73
+ const stderr = error.stderr?.toString("utf8").trim().replace(/[\r\n]+/g, " ");
74
+ throw new Error(`could not inspect repository DNA${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
75
+ }
76
+ }
77
+ function gitText(root, args) {
78
+ return gitBytes(root, args).toString("utf8").trim();
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 committedFile(root, revision, path) {
89
+ const type = execFileSync("git", ["-C", root, "cat-file", "-t", `${revision}:${path}`], {
90
+ encoding: "utf8",
91
+ env: gitEnvironment(),
92
+ maxBuffer: 1024,
93
+ stdio: ["ignore", "pipe", "ignore"],
94
+ timeout: 5_000,
95
+ }).trim();
96
+ if (type !== "blob")
97
+ return null;
98
+ const sizeText = gitText(root, ["cat-file", "-s", `${revision}:${path}`]);
99
+ const size = Number(sizeText);
100
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_FILE_BYTES)
101
+ return null;
102
+ const bytes = gitBytes(root, ["show", `${revision}:${path}`], MAX_FILE_BYTES + 1);
103
+ if (bytes.byteLength !== size || bytes.includes(0))
104
+ return null;
105
+ return bytes;
106
+ }
107
+ function tryCommittedFile(root, revision, path) {
108
+ try {
109
+ return committedFile(root, revision, path);
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ function confidence(ratio, sampleCount, floor = 0.6) {
116
+ const boundedRatio = Math.max(0, Math.min(1, ratio));
117
+ const sampleFactor = Math.min(1, sampleCount / 30);
118
+ return Number(Math.max(floor, boundedRatio * (0.75 + 0.25 * sampleFactor)).toFixed(3));
119
+ }
120
+ function traitId(category, key, claim) {
121
+ return `pdnat_${sha256(canonical({ category, key, claim })).slice("sha256:".length, "sha256:".length + 20)}`;
122
+ }
123
+ function makeTrait(category, key, claim, confidenceValue, evidence) {
124
+ return {
125
+ id: traitId(category, key, claim),
126
+ category,
127
+ key,
128
+ claim,
129
+ confidence: Number(Math.max(0, Math.min(1, confidenceValue)).toFixed(3)),
130
+ observation_state: "observed",
131
+ freshness: "current",
132
+ contradiction: "none",
133
+ evidence: [...evidence].sort((left, right) => compareCodeUnits(left.ref, right.ref)).slice(0, MAX_EVIDENCE),
134
+ };
135
+ }
136
+ function historyEvidence(revision, subjects) {
137
+ return {
138
+ kind: "git-history",
139
+ ref: `git:subjects:${subjects.length}`,
140
+ revision,
141
+ content_hash: sha256(subjects.join("\0")),
142
+ sample_count: subjects.length,
143
+ provenance: "committed-repository",
144
+ visibility: "repository",
145
+ };
146
+ }
147
+ function fileEvidence(revision, path, bytes) {
148
+ return {
149
+ kind: "committed-file",
150
+ ref: path,
151
+ revision,
152
+ content_hash: sha256(bytes),
153
+ sample_count: 1,
154
+ provenance: "committed-repository",
155
+ visibility: "repository",
156
+ };
157
+ }
158
+ function repositoryId(root, revision) {
159
+ const roots = gitText(root, ["rev-list", "--max-parents=0", revision, "--"])
160
+ .split("\n")
161
+ .map((value) => value.trim())
162
+ .filter(Boolean)
163
+ .sort(compareCodeUnits);
164
+ if (!roots.length || roots.some((value) => !GIT_OBJECT.test(value))) {
165
+ throw new Error("Git did not return a stable repository lineage identity");
166
+ }
167
+ return `pdnar_${sha256(canonical({ roots })).slice("sha256:".length, "sha256:".length + 24)}`;
168
+ }
169
+ function firstAlphabetic(value) {
170
+ const match = value.match(/[A-Za-z]/);
171
+ return match?.[0] ?? null;
172
+ }
173
+ function conventionalSubject(subject) {
174
+ return /^(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\([^)\r\n]{1,80}\))?!?:\s\S/.test(subject);
175
+ }
176
+ function collectHistoryTraits(revision, subjects) {
177
+ if (subjects.length < MIN_HISTORY)
178
+ return [];
179
+ const evidence = [historyEvidence(revision, subjects)];
180
+ const traits = [];
181
+ const count = subjects.length;
182
+ const conventional = subjects.filter(conventionalSubject).length;
183
+ const noTerminalPeriod = subjects.filter((subject) => !/[.!?]$/.test(subject.trim())).length;
184
+ const lowercase = subjects.filter((subject) => {
185
+ const first = firstAlphabetic(subject.replace(/^(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\([^)]+\))?!?:\s*/, ""));
186
+ return first !== null && first === first.toLowerCase();
187
+ }).length;
188
+ const issueRefs = subjects.filter((subject) => /(?:^|\s)#\d+\b/.test(subject)).length;
189
+ if (conventional / count >= 0.7) {
190
+ traits.push(makeTrait("communication", "commit.conventional", "Commit subjects usually use Conventional Commit prefixes.", confidence(conventional / count, count), evidence));
191
+ }
192
+ if (noTerminalPeriod / count >= 0.8) {
193
+ traits.push(makeTrait("communication", "subject.no_terminal_punctuation", "Change titles usually omit terminal punctuation.", confidence(noTerminalPeriod / count, count), evidence));
194
+ }
195
+ if (lowercase / count >= 0.7) {
196
+ traits.push(makeTrait("communication", "subject.lowercase_lead", "Change titles usually begin their descriptive phrase with lowercase wording.", confidence(lowercase / count, count), evidence));
197
+ }
198
+ if (issueRefs / count >= 0.45) {
199
+ traits.push(makeTrait("communication", "subject.issue_reference", "Change titles frequently reference a GitHub issue number.", confidence(issueRefs / count, count), evidence));
200
+ }
201
+ const words = new Map();
202
+ for (const subject of subjects) {
203
+ const normalized = subject
204
+ .replace(/^(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\([^)]+\))?!?:\s*/, "")
205
+ .toLowerCase();
206
+ for (const token of normalized.match(/[a-z][a-z0-9_-]{2,30}/g) ?? []) {
207
+ if (STOP_WORDS.has(token) || /^\d+$/.test(token))
208
+ continue;
209
+ words.set(token, (words.get(token) ?? 0) + 1);
210
+ }
211
+ }
212
+ const vocabulary = [...words.entries()]
213
+ .filter(([, occurrences]) => occurrences >= Math.max(3, Math.ceil(count * 0.08)))
214
+ .sort((left, right) => right[1] - left[1] || compareCodeUnits(left[0], right[0]))
215
+ .slice(0, 8);
216
+ for (const [word, occurrences] of vocabulary) {
217
+ traits.push(makeTrait("vocabulary", `term.${word}`, `The repository repeatedly uses the term “${word}” in change titles.`, confidence(occurrences / count, count, 0.55), evidence));
218
+ }
219
+ return traits;
220
+ }
221
+ const FILE_RULES = [
222
+ {
223
+ category: "review",
224
+ key: "review.tests_expected",
225
+ claim: "Contributions are expected to include or update tests when behavior changes.",
226
+ pattern: /(?:must|required|please|should|ensure|include|add|write)[^\n.]{0,80}\btests?\b|\btests?\b[^\n.]{0,80}(?:must|required|should|expected)/i,
227
+ },
228
+ {
229
+ category: "review",
230
+ key: "review.focused_changes",
231
+ claim: "Contributions are expected to stay focused and avoid unrelated changes.",
232
+ pattern: /\b(?:small|focused|narrow|scoped)\b[^\n.]{0,60}\b(?:pull request|pr|change|commit)s?\b|\b(?:unrelated|drive-by)\b[^\n.]{0,60}\b(?:change|cleanup|refactor)s?\b/i,
233
+ },
234
+ {
235
+ category: "culture",
236
+ key: "culture.backward_compatibility",
237
+ claim: "Backward compatibility is an explicit project concern.",
238
+ pattern: /\bbackward(?:s)?[- ]compatib|\bbreaking change\b|\bpublic api\b[^\n.]{0,60}\bcompatib/i,
239
+ },
240
+ {
241
+ category: "engineering",
242
+ key: "engineering.documentation_expected",
243
+ claim: "User-visible or public-facing changes are expected to update documentation.",
244
+ pattern: /(?:must|required|please|should|ensure|include|update)[^\n.]{0,80}\b(?:docs?|documentation|readme|changelog)\b/i,
245
+ },
246
+ {
247
+ category: "communication",
248
+ key: "pr.explain_why",
249
+ claim: "Pull requests are expected to explain motivation or rationale, not only the code change.",
250
+ pattern: /\b(?:why|motivation|rationale|reason)\b[^\n]{0,100}\b(?:change|pull request|pr|solution|approach)\b|\bwhat and why\b/i,
251
+ },
252
+ ];
253
+ function collectFileTraits(revision, files) {
254
+ const byKey = new Map();
255
+ for (const file of files) {
256
+ const text = file.bytes.toString("utf8");
257
+ for (const rule of FILE_RULES) {
258
+ if (!rule.pattern.test(text))
259
+ continue;
260
+ const entry = byKey.get(rule.key) ?? { rule, evidence: [] };
261
+ entry.evidence.push(fileEvidence(revision, file.path, file.bytes));
262
+ byKey.set(rule.key, entry);
263
+ }
264
+ }
265
+ return [...byKey.values()].map(({ rule, evidence }) => makeTrait(rule.category, rule.key, rule.claim, Math.min(0.98, 0.8 + Math.min(3, evidence.length) * 0.05), evidence));
266
+ }
267
+ function dedupeTraits(traits) {
268
+ const byKey = new Map();
269
+ for (const trait of traits) {
270
+ const existing = byKey.get(trait.key);
271
+ if (!existing || trait.confidence > existing.confidence)
272
+ byKey.set(trait.key, trait);
273
+ }
274
+ return [...byKey.values()]
275
+ .sort((left, right) => compareCodeUnits(`${left.category}\0${left.key}`, `${right.category}\0${right.key}`))
276
+ .slice(0, MAX_TRAITS);
277
+ }
278
+ /**
279
+ * Derive a deterministic repository DNA profile from one exact Git revision.
280
+ *
281
+ * This is intentionally observation, not authority: it reads bounded committed
282
+ * history and bounded committed convention files. It does not read the worktree,
283
+ * network, GitHub reviews, model output, or private user state, and it never writes
284
+ * into the durable Hunch graph by itself.
285
+ */
286
+ export function discoverProjectDna(root, ref = "HEAD") {
287
+ const repositoryRevision = exactCommit(root, ref);
288
+ const repositoryIdentity = repositoryId(root, repositoryRevision);
289
+ const historyRaw = gitText(root, [
290
+ "log", repositoryRevision, "--no-merges", `--max-count=${MAX_HISTORY}`, "--format=%s", "--",
291
+ ]);
292
+ const subjects = historyRaw ? historyRaw.split("\n").map((value) => value.trim()).filter(Boolean) : [];
293
+ const files = [];
294
+ for (const path of SOURCE_FILES) {
295
+ const bytes = tryCommittedFile(root, repositoryRevision, path);
296
+ if (bytes)
297
+ files.push({ path, bytes });
298
+ }
299
+ const traits = dedupeTraits([
300
+ ...collectHistoryTraits(repositoryRevision, subjects),
301
+ ...collectFileTraits(repositoryRevision, files),
302
+ ]);
303
+ const unsigned = {
304
+ schema: PROJECT_DNA_SCHEMA_VERSION,
305
+ repository_id: repositoryIdentity,
306
+ repository_revision: repositoryRevision,
307
+ history_sample_count: subjects.length,
308
+ source_files: files.map((file) => file.path).sort(compareCodeUnits),
309
+ traits,
310
+ };
311
+ const profileId = `pdna_${sha256(canonical(unsigned)).slice("sha256:".length, "sha256:".length + 24)}`;
312
+ const sealed = { ...unsigned, profile_id: profileId };
313
+ const profile = { ...sealed, content_hash: sha256(canonical(sealed)) };
314
+ assertProjectDnaProfile(profile);
315
+ return profile;
316
+ }
317
+ function expectedTraitFields() {
318
+ return ["id", "category", "key", "claim", "confidence", "observation_state", "freshness", "contradiction", "evidence"].sort(compareCodeUnits);
319
+ }
320
+ function assertExactFields(value, fields, label) {
321
+ if (Object.keys(value).sort(compareCodeUnits).join("\0") !== [...fields].sort(compareCodeUnits).join("\0")) {
322
+ throw new Error(`${label} fields are invalid`);
323
+ }
324
+ }
325
+ export function assertProjectDnaProfile(value) {
326
+ if (!value || typeof value !== "object" || Array.isArray(value))
327
+ throw new Error("project DNA profile is invalid");
328
+ const profile = value;
329
+ assertExactFields(value, [
330
+ "schema", "profile_id", "repository_id", "repository_revision", "history_sample_count", "source_files", "traits", "content_hash",
331
+ ], "project DNA profile");
332
+ if (profile.schema !== PROJECT_DNA_SCHEMA_VERSION || !PROFILE_ID.test(profile.profile_id)
333
+ || !REPOSITORY_ID.test(profile.repository_id) || !GIT_OBJECT.test(profile.repository_revision) || !Number.isSafeInteger(profile.history_sample_count)
334
+ || profile.history_sample_count < 0 || profile.history_sample_count > MAX_HISTORY
335
+ || !Array.isArray(profile.source_files) || profile.source_files.length > SOURCE_FILES.length
336
+ || profile.source_files.some((path) => typeof path !== "string" || !SOURCE_FILES.includes(path))
337
+ || [...profile.source_files].sort(compareCodeUnits).join("\0") !== profile.source_files.join("\0")
338
+ || !Array.isArray(profile.traits) || profile.traits.length > MAX_TRAITS || !SHA256.test(profile.content_hash)) {
339
+ throw new Error("project DNA profile fields are invalid");
340
+ }
341
+ const seen = new Set();
342
+ for (const trait of profile.traits) {
343
+ if (!trait || typeof trait !== "object" || Array.isArray(trait))
344
+ throw new Error("project DNA trait is invalid");
345
+ assertExactFields(trait, expectedTraitFields(), "project DNA trait");
346
+ if (!/^pdnat_[a-f0-9]{20}$/.test(trait.id) || !PROJECT_DNA_CATEGORIES.includes(trait.category)
347
+ || !/^[a-z][a-z0-9_.-]{2,100}$/.test(trait.key) || !trait.claim.trim() || trait.claim.length > 500
348
+ || !Number.isFinite(trait.confidence) || trait.confidence < 0 || trait.confidence > 1
349
+ || trait.observation_state !== "observed" || trait.freshness !== "current" || trait.contradiction !== "none"
350
+ || !Array.isArray(trait.evidence) || trait.evidence.length < 1 || trait.evidence.length > MAX_EVIDENCE
351
+ || seen.has(trait.key)) {
352
+ throw new Error("project DNA trait fields are invalid");
353
+ }
354
+ seen.add(trait.key);
355
+ if (trait.id !== traitId(trait.category, trait.key, trait.claim))
356
+ throw new Error("project DNA trait identity is invalid");
357
+ for (const evidence of trait.evidence) {
358
+ if (!evidence || typeof evidence !== "object" || Array.isArray(evidence))
359
+ throw new Error("project DNA evidence is invalid");
360
+ assertExactFields(evidence, [
361
+ "kind", "ref", "revision", "content_hash", "sample_count", "provenance", "visibility",
362
+ ], "project DNA evidence");
363
+ if (!["git-history", "committed-file"].includes(evidence.kind) || !evidence.ref.trim() || evidence.ref.length > 512
364
+ || evidence.revision !== profile.repository_revision || !SHA256.test(evidence.content_hash)
365
+ || !Number.isSafeInteger(evidence.sample_count) || evidence.sample_count < 1 || evidence.sample_count > MAX_HISTORY
366
+ || evidence.provenance !== "committed-repository" || evidence.visibility !== "repository") {
367
+ throw new Error("project DNA evidence fields are invalid");
368
+ }
369
+ }
370
+ }
371
+ const { content_hash: _contentHash, profile_id: _profileId, ...base } = profile;
372
+ const expectedProfileId = `pdna_${sha256(canonical(base)).slice("sha256:".length, "sha256:".length + 24)}`;
373
+ const sealed = { ...base, profile_id: profile.profile_id };
374
+ if (profile.profile_id !== expectedProfileId || profile.content_hash !== sha256(canonical(sealed))) {
375
+ throw new Error("project DNA profile seal is invalid");
376
+ }
377
+ }
378
+ function artifactCheck(trait, artifact) {
379
+ const title = artifact.title.trim();
380
+ const first = firstAlphabetic(title.replace(/^(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\([^)]+\))?!?:\s*/, ""));
381
+ const weight = Math.max(1, Math.round(trait.confidence * 100));
382
+ switch (trait.key) {
383
+ case "commit.conventional":
384
+ return artifact.kind === "commit"
385
+ ? { trait_id: trait.id, key: trait.key, applicable: true, passed: conventionalSubject(title), weight, detail: "Commit subject follows the repository's observed Conventional Commit pattern." }
386
+ : { trait_id: trait.id, key: trait.key, applicable: false, passed: null, weight, detail: "This trait applies only to commit subjects." };
387
+ case "subject.no_terminal_punctuation":
388
+ return { trait_id: trait.id, key: trait.key, applicable: true, passed: !/[.!?]$/.test(title), weight, detail: "Title omits terminal punctuation." };
389
+ case "subject.lowercase_lead":
390
+ return { trait_id: trait.id, key: trait.key, applicable: first !== null, passed: first === null ? null : first === first.toLowerCase(), weight, detail: "Descriptive title wording begins lowercase." };
391
+ case "subject.issue_reference":
392
+ return { trait_id: trait.id, key: trait.key, applicable: true, passed: /(?:^|\s)#\d+\b/.test(title), weight, detail: "Title carries an issue reference." };
393
+ case "pr.explain_why": {
394
+ const body = artifact.body?.trim() ?? "";
395
+ const applicable = artifact.kind === "pull_request";
396
+ const passed = applicable ? /\b(?:why|because|motivation|rationale|reason)\b/i.test(body) : null;
397
+ return { trait_id: trait.id, key: trait.key, applicable, passed, weight, detail: "PR body contains an explicit rationale signal." };
398
+ }
399
+ default:
400
+ return { trait_id: trait.id, key: trait.key, applicable: false, passed: null, weight, detail: "Trait is orientation-only and has no deterministic artifact check yet." };
401
+ }
402
+ }
403
+ /** Score only traits that have a deterministic check for the supplied artifact. */
404
+ export function evaluateProjectDnaMatch(profileValue, artifact) {
405
+ assertProjectDnaProfile(profileValue);
406
+ const profile = profileValue;
407
+ if (!artifact || !["commit", "pull_request", "issue", "message"].includes(artifact.kind)
408
+ || typeof artifact.title !== "string" || artifact.title.length < 1 || artifact.title.length > 1_000
409
+ || (artifact.body !== undefined && (typeof artifact.body !== "string" || artifact.body.length > 20_000))) {
410
+ throw new Error("project DNA artifact is invalid");
411
+ }
412
+ const checks = profile.traits.map((trait) => artifactCheck(trait, artifact));
413
+ const applicable = checks.filter((check) => check.applicable && check.passed !== null);
414
+ const totalWeight = applicable.reduce((sum, check) => sum + check.weight, 0);
415
+ const passedWeight = applicable.reduce((sum, check) => sum + (check.passed ? check.weight : 0), 0);
416
+ const score = totalWeight > 0 ? Number(((passedWeight / totalWeight) * 100).toFixed(1)) : null;
417
+ const unsigned = {
418
+ schema: PROJECT_DNA_MATCH_SCHEMA_VERSION,
419
+ profile_id: profile.profile_id,
420
+ repository_id: profile.repository_id,
421
+ repository_revision: profile.repository_revision,
422
+ artifact_kind: artifact.kind,
423
+ score,
424
+ applicable_checks: applicable.length,
425
+ checks,
426
+ };
427
+ // The identity is derived from the public envelope, rather than from artifact
428
+ // bytes that are deliberately not retained. That makes a received match fully
429
+ // self-validating without storing PR/issue bodies in Hunch.
430
+ const matchId = `pdnam_${sha256(canonical(unsigned)).slice("sha256:".length, "sha256:".length + 24)}`;
431
+ const sealed = { ...unsigned, match_id: matchId };
432
+ return { ...sealed, content_hash: sha256(canonical(sealed)) };
433
+ }
434
+ export function assertProjectDnaMatch(value) {
435
+ if (!value || typeof value !== "object" || Array.isArray(value))
436
+ throw new Error("project DNA match is invalid");
437
+ const match = value;
438
+ assertExactFields(value, [
439
+ "schema", "match_id", "profile_id", "repository_id", "repository_revision", "artifact_kind", "score", "applicable_checks", "checks", "content_hash",
440
+ ], "project DNA match");
441
+ if (match.schema !== PROJECT_DNA_MATCH_SCHEMA_VERSION || !MATCH_ID.test(match.match_id) || !PROFILE_ID.test(match.profile_id)
442
+ || !REPOSITORY_ID.test(match.repository_id) || !GIT_OBJECT.test(match.repository_revision)
443
+ || !["commit", "pull_request", "issue", "message"].includes(match.artifact_kind)
444
+ || (match.score !== null && (!Number.isFinite(match.score) || match.score < 0 || match.score > 100))
445
+ || !Number.isSafeInteger(match.applicable_checks) || match.applicable_checks < 0
446
+ || !Array.isArray(match.checks) || match.applicable_checks > match.checks.length || !SHA256.test(match.content_hash)) {
447
+ throw new Error("project DNA match fields are invalid");
448
+ }
449
+ const traitIds = new Set();
450
+ for (const check of match.checks) {
451
+ if (!check || typeof check !== "object" || Array.isArray(check))
452
+ throw new Error("project DNA match check is invalid");
453
+ assertExactFields(check, [
454
+ "trait_id", "key", "applicable", "passed", "weight", "detail",
455
+ ], "project DNA match check");
456
+ if (!/^pdnat_[a-f0-9]{20}$/.test(check.trait_id) || traitIds.has(check.trait_id)
457
+ || !/^[a-z][a-z0-9_.-]{2,100}$/.test(check.key)
458
+ || typeof check.applicable !== "boolean"
459
+ || !(check.passed === true || check.passed === false || check.passed === null)
460
+ || (check.applicable ? check.passed === null : check.passed !== null)
461
+ || !Number.isSafeInteger(check.weight) || check.weight < 1 || check.weight > 100
462
+ || typeof check.detail !== "string" || !check.detail.trim() || check.detail.length > 500) {
463
+ throw new Error("project DNA match check fields are invalid");
464
+ }
465
+ traitIds.add(check.trait_id);
466
+ }
467
+ const applicable = match.checks.filter((check) => check.applicable).length;
468
+ if (applicable !== match.applicable_checks)
469
+ throw new Error("project DNA match applicable count is invalid");
470
+ const { content_hash: _contentHash, match_id: _matchId, ...base } = match;
471
+ const expectedMatchId = `pdnam_${sha256(canonical(base)).slice("sha256:".length, "sha256:".length + 24)}`;
472
+ const unsigned = { ...base, match_id: match.match_id };
473
+ if (match.match_id !== expectedMatchId)
474
+ throw new Error("project DNA match identity is invalid");
475
+ if (match.content_hash !== sha256(canonical(unsigned)))
476
+ throw new Error("project DNA match seal is invalid");
477
+ }
478
+ //# sourceMappingURL=projectDna.js.map
@@ -0,0 +1,54 @@
1
+ import { compareCodeUnits } from "./canonicalOrder.js";
2
+ import { assertProjectDnaProfile } from "./projectDna.js";
3
+ export const PROJECT_DNA_SUPPLEMENT_KIND = "project-dna";
4
+ const DEFAULT_TRAIT_CAP = 8;
5
+ const MAX_TRAIT_CAP = 16;
6
+ const CATEGORY_ORDER = {
7
+ communication: 0,
8
+ review: 1,
9
+ engineering: 2,
10
+ culture: 3,
11
+ vocabulary: 4,
12
+ };
13
+ function orderedTraits(profile) {
14
+ return [...profile.traits].sort((left, right) => CATEGORY_ORDER[left.category] - CATEGORY_ORDER[right.category]
15
+ || right.confidence - left.confidence
16
+ || compareCodeUnits(left.key, right.key));
17
+ }
18
+ /**
19
+ * Render Project DNA through Hunch's existing DeliverySupplement seam.
20
+ *
21
+ * The caller still owns the final hard budget via buildDeliveryEnvelope(); this
22
+ * function only prepares compact, evidence-identifiable orientation text. The
23
+ * profile ID/revision remain visible so a host can preserve provider provenance.
24
+ */
25
+ export function projectDnaDeliverySupplement(profileValue, traitCap = DEFAULT_TRAIT_CAP) {
26
+ assertProjectDnaProfile(profileValue);
27
+ const profile = profileValue;
28
+ if (!Number.isSafeInteger(traitCap) || traitCap < 1 || traitCap > MAX_TRAIT_CAP) {
29
+ throw new Error(`project DNA trait cap must be an integer between 1 and ${MAX_TRAIT_CAP}`);
30
+ }
31
+ const selected = orderedTraits(profile).slice(0, traitCap);
32
+ if (!selected.length)
33
+ return null;
34
+ const lines = selected.map((trait) => {
35
+ const evidence = trait.evidence.map((item) => item.ref).slice(0, 2).join(", ");
36
+ return `• [${trait.category}] ${trait.claim} (${trait.confidence.toFixed(2)}; ${trait.id}; evidence: ${evidence})`;
37
+ });
38
+ const omitted = Math.max(0, profile.traits.length - selected.length);
39
+ const text = [
40
+ `PROJECT DNA — observed repository conventions (advisory, ${profile.profile_id}, revision ${profile.repository_revision})`,
41
+ ...lines,
42
+ omitted ? `• … ${omitted} lower-priority DNA trait(s) omitted from this orientation slice.` : "",
43
+ "Use these traits to communicate and contribute naturally. They never override Hunch decisions, constraints, policy, or current task evidence.",
44
+ ].filter(Boolean).join("\n");
45
+ return {
46
+ id: profile.profile_id,
47
+ kind: PROJECT_DNA_SUPPLEMENT_KIND,
48
+ text,
49
+ // Ranked memory and blocking invariants remain above this supplement. Hosts
50
+ // may lower this further, but should not raise observational DNA over authority.
51
+ priority: 425,
52
+ };
53
+ }
54
+ //# sourceMappingURL=projectDnaDelivery.js.map