@davesheffer/hunch 1.21.1 → 1.22.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 CHANGED
@@ -174,7 +174,7 @@ repository, separate from the code repository. Hunch does not host it. Create a
174
174
  that every teammate can access, install Hunch on team machines and CI, then have one maintainer run:
175
175
 
176
176
  ```bash
177
- npm i -g @davesheffer/hunch@1.21.1
177
+ npm i -g @davesheffer/hunch@1.22.0
178
178
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
179
179
  git add .gitignore .hunch/team.json
180
180
  git commit -m "chore: connect shared Hunch memory"
@@ -189,7 +189,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
189
189
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
190
190
 
191
191
  ```bash
192
- npm i -g @davesheffer/hunch@1.21.1
192
+ npm i -g @davesheffer/hunch@1.22.0
193
193
  git pull
194
194
  hunch init
195
195
  hunch doctor
@@ -0,0 +1,81 @@
1
+ import { type ProjectDnaHostEvidence } from "./projectDnaHostEvidence.js";
2
+ export declare const PROJECT_DNA_SCHEMA_VERSION: "hunch.project-dna/1";
3
+ export declare const PROJECT_DNA_MATCH_SCHEMA_VERSION: "hunch.project-dna-match/1";
4
+ export declare const PROJECT_DNA_CATEGORIES: readonly ["communication", "engineering", "review", "culture", "vocabulary"];
5
+ export type ProjectDnaCategory = (typeof PROJECT_DNA_CATEGORIES)[number];
6
+ export type ProjectDnaEvidenceKind = "git-history" | "committed-file" | "host-evidence";
7
+ export interface ProjectDnaEvidence {
8
+ kind: ProjectDnaEvidenceKind;
9
+ ref: string;
10
+ revision: string;
11
+ content_hash: string;
12
+ sample_count: number;
13
+ provenance: "committed-repository" | "host-provided";
14
+ visibility: "repository";
15
+ }
16
+ export interface ProjectDnaTrait {
17
+ id: string;
18
+ category: ProjectDnaCategory;
19
+ key: string;
20
+ claim: string;
21
+ confidence: number;
22
+ observation_state: "observed";
23
+ freshness: "current";
24
+ contradiction: "none";
25
+ evidence: ProjectDnaEvidence[];
26
+ }
27
+ export interface ProjectDnaProfile {
28
+ schema: typeof PROJECT_DNA_SCHEMA_VERSION;
29
+ profile_id: string;
30
+ /** Clone-stable identity for the repository lineage, derived from its root commits. */
31
+ repository_id: string;
32
+ repository_revision: string;
33
+ history_sample_count: number;
34
+ source_files: string[];
35
+ traits: ProjectDnaTrait[];
36
+ content_hash: string;
37
+ }
38
+ export interface ProjectDnaDiscoveryOptions {
39
+ /** A sealed batch selected and authorized by the host for this exact revision. */
40
+ hostEvidence?: ProjectDnaHostEvidence;
41
+ }
42
+ export interface ProjectDnaArtifact {
43
+ kind: "commit" | "pull_request" | "issue" | "message";
44
+ title: string;
45
+ body?: string;
46
+ }
47
+ export interface ProjectDnaMatchCheck {
48
+ trait_id: string;
49
+ key: string;
50
+ applicable: boolean;
51
+ passed: boolean | null;
52
+ weight: number;
53
+ detail: string;
54
+ }
55
+ export interface ProjectDnaMatch {
56
+ schema: typeof PROJECT_DNA_MATCH_SCHEMA_VERSION;
57
+ match_id: string;
58
+ profile_id: string;
59
+ repository_id: string;
60
+ repository_revision: string;
61
+ artifact_kind: ProjectDnaArtifact["kind"];
62
+ score: number | null;
63
+ applicable_checks: number;
64
+ checks: ProjectDnaMatchCheck[];
65
+ content_hash: string;
66
+ }
67
+ /**
68
+ * Derive a deterministic repository DNA profile from one exact Git revision.
69
+ *
70
+ * This is intentionally observation, not authority: it reads bounded committed
71
+ * history and bounded committed convention files. A host may additionally pass a
72
+ * sealed, revision-bound evidence batch that it already authorized; discovery
73
+ * never fetches a provider itself. It does not read the worktree, network, model
74
+ * output, credentials, or private user state, and it never writes into the durable
75
+ * Hunch graph by itself.
76
+ */
77
+ export declare function discoverProjectDna(root: string, ref?: string, options?: ProjectDnaDiscoveryOptions): ProjectDnaProfile;
78
+ export declare function assertProjectDnaProfile(value: unknown): asserts value is ProjectDnaProfile;
79
+ /** Score only traits that have a deterministic check for the supplied artifact. */
80
+ export declare function evaluateProjectDnaMatch(profileValue: unknown, artifact: ProjectDnaArtifact): ProjectDnaMatch;
81
+ export declare function assertProjectDnaMatch(value: unknown): asserts value is ProjectDnaMatch;
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { compareCodeUnits } from "./canonicalOrder.js";
4
+ import { assertProjectDnaHostEvidence, } from "./projectDnaHostEvidence.js";
4
5
  export const PROJECT_DNA_SCHEMA_VERSION = "hunch.project-dna/1";
5
6
  export const PROJECT_DNA_MATCH_SCHEMA_VERSION = "hunch.project-dna-match/1";
6
7
  const GIT_OBJECT = /^[a-f0-9]{40,64}$/;
@@ -155,6 +156,17 @@ function fileEvidence(revision, path, bytes) {
155
156
  visibility: "repository",
156
157
  };
157
158
  }
159
+ function hostEvidenceEvidence(revision, hostEvidence, sampleCount) {
160
+ return {
161
+ kind: "host-evidence",
162
+ ref: `host:${hostEvidence.evidence_set_id}`,
163
+ revision,
164
+ content_hash: hostEvidence.content_hash,
165
+ sample_count: sampleCount,
166
+ provenance: "host-provided",
167
+ visibility: "repository",
168
+ };
169
+ }
158
170
  function repositoryId(root, revision) {
159
171
  const roots = gitText(root, ["rev-list", "--max-parents=0", revision, "--"])
160
172
  .split("\n")
@@ -264,6 +276,70 @@ function collectFileTraits(revision, files) {
264
276
  }
265
277
  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
278
  }
279
+ function collectHostEvidenceTraits(revision, hostEvidence) {
280
+ if (!hostEvidence)
281
+ return [];
282
+ assertProjectDnaHostEvidence(hostEvidence);
283
+ if (hostEvidence.repository_revision !== revision) {
284
+ throw new Error("Project DNA host evidence revision does not match the repository revision");
285
+ }
286
+ const traits = [];
287
+ const pullRequests = hostEvidence.items.filter((item) => item.kind === "pull_request" && item.disposition === "merged");
288
+ if (pullRequests.length >= MIN_HISTORY) {
289
+ const titles = pullRequests.map((item) => item.title);
290
+ const evidence = [hostEvidenceEvidence(revision, hostEvidence, pullRequests.length)];
291
+ const count = titles.length;
292
+ const conventional = titles.filter(conventionalSubject).length;
293
+ const noTerminalPeriod = titles.filter((title) => !/[.!?]$/.test(title.trim())).length;
294
+ const lowercase = titles.filter((title) => {
295
+ const first = firstAlphabetic(title.replace(/^(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\([^)]+\))?!?:\s*/, ""));
296
+ return first !== null && first === first.toLowerCase();
297
+ }).length;
298
+ const issueRefs = titles.filter((title) => /(?:^|\s)#\d+\b/.test(title)).length;
299
+ if (conventional / count >= 0.7) {
300
+ traits.push(makeTrait("communication", "pull_request.conventional_title", "Pull-request titles usually use Conventional Commit prefixes.", confidence(conventional / count, count), evidence));
301
+ }
302
+ if (noTerminalPeriod / count >= 0.8) {
303
+ traits.push(makeTrait("communication", "pull_request.no_terminal_punctuation", "Pull-request titles usually omit terminal punctuation.", confidence(noTerminalPeriod / count, count), evidence));
304
+ }
305
+ if (lowercase / count >= 0.7) {
306
+ traits.push(makeTrait("communication", "pull_request.lowercase_lead", "Pull-request titles usually begin their descriptive phrase with lowercase wording.", confidence(lowercase / count, count), evidence));
307
+ }
308
+ if (issueRefs / count >= 0.45) {
309
+ traits.push(makeTrait("communication", "pull_request.issue_reference", "Pull-request titles frequently reference an issue number.", confidence(issueRefs / count, count), evidence));
310
+ }
311
+ const words = new Map();
312
+ for (const title of titles) {
313
+ const seen = new Set((title
314
+ .replace(/^(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\([^)]+\))?!?:\s*/, "")
315
+ .toLowerCase()
316
+ .match(/[a-z][a-z0-9_-]{2,30}/g) ?? [])
317
+ .filter((token) => !STOP_WORDS.has(token) && !/^\d+$/.test(token)));
318
+ for (const token of seen)
319
+ words.set(token, (words.get(token) ?? 0) + 1);
320
+ }
321
+ const vocabulary = [...words.entries()]
322
+ .filter(([, occurrences]) => occurrences >= Math.max(3, Math.ceil(count * 0.2)))
323
+ .sort((left, right) => right[1] - left[1] || compareCodeUnits(left[0], right[0]))
324
+ .slice(0, 8);
325
+ for (const [word, occurrences] of vocabulary) {
326
+ traits.push(makeTrait("vocabulary", `term.${word}`, `The repository repeatedly uses the term “${word}” in merged pull-request titles.`, confidence(occurrences / count, count, 0.55), evidence));
327
+ }
328
+ const bodies = pullRequests.map((item) => item.body).filter((body) => body !== null);
329
+ const rationaleCount = bodies.filter((body) => FILE_RULES.find((rule) => rule.key === "pr.explain_why").pattern.test(body)).length;
330
+ if (rationaleCount >= 2 && rationaleCount / Math.max(1, bodies.length) >= 0.6) {
331
+ traits.push(makeTrait("communication", "pr.explain_why", "Pull requests are expected to explain motivation or rationale, not only the code change.", confidence(rationaleCount / bodies.length, bodies.length), [hostEvidenceEvidence(revision, hostEvidence, rationaleCount)]));
332
+ }
333
+ }
334
+ const maintainerReviews = hostEvidence.items.filter((item) => item.kind === "review_comment" && item.author_role === "maintainer");
335
+ for (const rule of FILE_RULES) {
336
+ const matching = maintainerReviews.filter((item) => rule.pattern.test(item.body));
337
+ if (matching.length < 2)
338
+ continue;
339
+ traits.push(makeTrait(rule.category, rule.key, rule.claim, confidence(matching.length / maintainerReviews.length, matching.length, 0.65), [hostEvidenceEvidence(revision, hostEvidence, matching.length)]));
340
+ }
341
+ return traits;
342
+ }
267
343
  function dedupeTraits(traits) {
268
344
  const byKey = new Map();
269
345
  for (const trait of traits) {
@@ -279,11 +355,13 @@ function dedupeTraits(traits) {
279
355
  * Derive a deterministic repository DNA profile from one exact Git revision.
280
356
  *
281
357
  * 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.
358
+ * history and bounded committed convention files. A host may additionally pass a
359
+ * sealed, revision-bound evidence batch that it already authorized; discovery
360
+ * never fetches a provider itself. It does not read the worktree, network, model
361
+ * output, credentials, or private user state, and it never writes into the durable
362
+ * Hunch graph by itself.
285
363
  */
286
- export function discoverProjectDna(root, ref = "HEAD") {
364
+ export function discoverProjectDna(root, ref = "HEAD", options = {}) {
287
365
  const repositoryRevision = exactCommit(root, ref);
288
366
  const repositoryIdentity = repositoryId(root, repositoryRevision);
289
367
  const historyRaw = gitText(root, [
@@ -299,6 +377,7 @@ export function discoverProjectDna(root, ref = "HEAD") {
299
377
  const traits = dedupeTraits([
300
378
  ...collectHistoryTraits(repositoryRevision, subjects),
301
379
  ...collectFileTraits(repositoryRevision, files),
380
+ ...collectHostEvidenceTraits(repositoryRevision, options.hostEvidence),
302
381
  ]);
303
382
  const unsigned = {
304
383
  schema: PROJECT_DNA_SCHEMA_VERSION,
@@ -360,10 +439,14 @@ export function assertProjectDnaProfile(value) {
360
439
  assertExactFields(evidence, [
361
440
  "kind", "ref", "revision", "content_hash", "sample_count", "provenance", "visibility",
362
441
  ], "project DNA evidence");
363
- if (!["git-history", "committed-file"].includes(evidence.kind) || !evidence.ref.trim() || evidence.ref.length > 512
442
+ const committedEvidence = evidence.kind === "git-history" || evidence.kind === "committed-file";
443
+ const hostEvidence = evidence.kind === "host-evidence";
444
+ if ((!committedEvidence && !hostEvidence) || !evidence.ref.trim() || evidence.ref.length > 512
364
445
  || evidence.revision !== profile.repository_revision || !SHA256.test(evidence.content_hash)
365
446
  || !Number.isSafeInteger(evidence.sample_count) || evidence.sample_count < 1 || evidence.sample_count > MAX_HISTORY
366
- || evidence.provenance !== "committed-repository" || evidence.visibility !== "repository") {
447
+ || (committedEvidence && evidence.provenance !== "committed-repository")
448
+ || (hostEvidence && (evidence.provenance !== "host-provided" || !/^host:pdnah_[a-f0-9]{24}$/.test(evidence.ref)))
449
+ || evidence.visibility !== "repository") {
367
450
  throw new Error("project DNA evidence fields are invalid");
368
451
  }
369
452
  }
@@ -390,6 +473,22 @@ function artifactCheck(trait, artifact) {
390
473
  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
474
  case "subject.issue_reference":
392
475
  return { trait_id: trait.id, key: trait.key, applicable: true, passed: /(?:^|\s)#\d+\b/.test(title), weight, detail: "Title carries an issue reference." };
476
+ case "pull_request.conventional_title":
477
+ return artifact.kind === "pull_request"
478
+ ? { trait_id: trait.id, key: trait.key, applicable: true, passed: conventionalSubject(title), weight, detail: "PR title follows the repository's observed Conventional Commit pattern." }
479
+ : { trait_id: trait.id, key: trait.key, applicable: false, passed: null, weight, detail: "This trait applies only to pull-request titles." };
480
+ case "pull_request.no_terminal_punctuation":
481
+ return artifact.kind === "pull_request"
482
+ ? { trait_id: trait.id, key: trait.key, applicable: true, passed: !/[.!?]$/.test(title), weight, detail: "PR title omits terminal punctuation." }
483
+ : { trait_id: trait.id, key: trait.key, applicable: false, passed: null, weight, detail: "This trait applies only to pull-request titles." };
484
+ case "pull_request.lowercase_lead":
485
+ return artifact.kind === "pull_request"
486
+ ? { trait_id: trait.id, key: trait.key, applicable: first !== null, passed: first === null ? null : first === first.toLowerCase(), weight, detail: "Descriptive PR-title wording begins lowercase." }
487
+ : { trait_id: trait.id, key: trait.key, applicable: false, passed: null, weight, detail: "This trait applies only to pull-request titles." };
488
+ case "pull_request.issue_reference":
489
+ return artifact.kind === "pull_request"
490
+ ? { trait_id: trait.id, key: trait.key, applicable: true, passed: /(?:^|\s)#\d+\b/.test(title), weight, detail: "PR title carries an issue reference." }
491
+ : { trait_id: trait.id, key: trait.key, applicable: false, passed: null, weight, detail: "This trait applies only to pull-request titles." };
393
492
  case "pr.explain_why": {
394
493
  const body = artifact.body?.trim() ?? "";
395
494
  const applicable = artifact.kind === "pull_request";
@@ -0,0 +1,15 @@
1
+ export declare const PROJECT_DNA_SUPPLEMENT_KIND: "project-dna";
2
+ export interface ProjectDnaDeliverySupplement {
3
+ id: string;
4
+ kind: typeof PROJECT_DNA_SUPPLEMENT_KIND;
5
+ text: string;
6
+ priority: number;
7
+ }
8
+ /**
9
+ * Render Project DNA through Hunch's existing DeliverySupplement seam.
10
+ *
11
+ * The caller still owns the final hard budget via buildDeliveryEnvelope(); this
12
+ * function only prepares compact, evidence-identifiable orientation text. The
13
+ * profile ID/revision remain visible so a host can preserve provider provenance.
14
+ */
15
+ export declare function projectDnaDeliverySupplement(profileValue: unknown, traitCap?: number): ProjectDnaDeliverySupplement | null;
@@ -0,0 +1,30 @@
1
+ export declare const PROJECT_DNA_DELTA_SCHEMA_VERSION: "hunch.project-dna-delta/1";
2
+ export type ProjectDnaChangeKind = "added" | "removed" | "evidence_changed" | "confidence_changed";
3
+ export interface ProjectDnaTraitChange {
4
+ key: string;
5
+ kind: ProjectDnaChangeKind;
6
+ before_trait_id: string | null;
7
+ after_trait_id: string | null;
8
+ before_confidence: number | null;
9
+ after_confidence: number | null;
10
+ }
11
+ export interface ProjectDnaDelta {
12
+ schema: typeof PROJECT_DNA_DELTA_SCHEMA_VERSION;
13
+ delta_id: string;
14
+ repository_id: string;
15
+ from_profile_id: string;
16
+ to_profile_id: string;
17
+ from_revision: string;
18
+ to_revision: string;
19
+ changes: ProjectDnaTraitChange[];
20
+ changed: boolean;
21
+ content_hash: string;
22
+ }
23
+ /**
24
+ * Compare two already-sealed profiles without inferring causality.
25
+ *
26
+ * A delta says only that observed DNA changed between exact revisions. It does
27
+ * not promote the new trait, explain why the change happened, or grant policy.
28
+ */
29
+ export declare function diffProjectDna(fromValue: unknown, toValue: unknown): ProjectDnaDelta;
30
+ export declare function assertProjectDnaDelta(value: unknown): asserts value is ProjectDnaDelta;
@@ -0,0 +1,36 @@
1
+ export declare const PROJECT_DNA_HOST_EVIDENCE_SCHEMA_VERSION: "hunch.project-dna-host-evidence/1";
2
+ export declare const PROJECT_DNA_HOST_EVIDENCE_KINDS: readonly ["pull_request", "review_comment"];
3
+ export declare const PROJECT_DNA_HOST_EVIDENCE_DISPOSITIONS: readonly ["merged", "approved", "changes_requested", "commented"];
4
+ export declare const PROJECT_DNA_HOST_EVIDENCE_AUTHOR_ROLES: readonly ["maintainer", "contributor", "unknown"];
5
+ export type ProjectDnaHostEvidenceKind = (typeof PROJECT_DNA_HOST_EVIDENCE_KINDS)[number];
6
+ export type ProjectDnaHostEvidenceDisposition = (typeof PROJECT_DNA_HOST_EVIDENCE_DISPOSITIONS)[number];
7
+ export type ProjectDnaHostEvidenceAuthorRole = (typeof PROJECT_DNA_HOST_EVIDENCE_AUTHOR_ROLES)[number];
8
+ export interface ProjectDnaHostEvidenceCandidate {
9
+ kind: ProjectDnaHostEvidenceKind;
10
+ /** Credential-free, repository-local source identity such as github:pull-request:42. */
11
+ ref: string;
12
+ disposition: ProjectDnaHostEvidenceDisposition;
13
+ author_role: ProjectDnaHostEvidenceAuthorRole;
14
+ title: string | null;
15
+ body: string | null;
16
+ }
17
+ export interface ProjectDnaHostEvidenceItem extends ProjectDnaHostEvidenceCandidate {
18
+ item_id: string;
19
+ content_hash: string;
20
+ }
21
+ export interface ProjectDnaHostEvidence {
22
+ schema: typeof PROJECT_DNA_HOST_EVIDENCE_SCHEMA_VERSION;
23
+ repository_revision: string;
24
+ items: ProjectDnaHostEvidenceItem[];
25
+ evidence_set_id: string;
26
+ content_hash: string;
27
+ }
28
+ /**
29
+ * Seal an explicitly authorized, repository-visible host evidence batch.
30
+ *
31
+ * This function never fetches a provider and never grants the host evidence
32
+ * policy authority. The caller owns source authorization and may pass only
33
+ * credential-free refs plus bounded repository-visible text.
34
+ */
35
+ export declare function sealProjectDnaHostEvidence(repositoryRevision: string, candidates: readonly ProjectDnaHostEvidenceCandidate[]): ProjectDnaHostEvidence;
36
+ export declare function assertProjectDnaHostEvidence(value: unknown): asserts value is ProjectDnaHostEvidence;
@@ -0,0 +1,174 @@
1
+ import { createHash } from "node:crypto";
2
+ import { compareCodeUnits } from "./canonicalOrder.js";
3
+ export const PROJECT_DNA_HOST_EVIDENCE_SCHEMA_VERSION = "hunch.project-dna-host-evidence/1";
4
+ const GIT_OBJECT = /^[a-f0-9]{40,64}$/;
5
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
6
+ const ITEM_ID = /^pdnahi_[a-f0-9]{20}$/;
7
+ const SET_ID = /^pdnah_[a-f0-9]{24}$/;
8
+ const SOURCE_REF = /^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$/;
9
+ const MAX_ITEMS = 64;
10
+ const MAX_TITLE = 500;
11
+ const MAX_BODY = 8_000;
12
+ export const PROJECT_DNA_HOST_EVIDENCE_KINDS = ["pull_request", "review_comment"];
13
+ export const PROJECT_DNA_HOST_EVIDENCE_DISPOSITIONS = [
14
+ "merged", "approved", "changes_requested", "commented",
15
+ ];
16
+ export const PROJECT_DNA_HOST_EVIDENCE_AUTHOR_ROLES = ["maintainer", "contributor", "unknown"];
17
+ function canonical(value) {
18
+ if (Array.isArray(value))
19
+ return `[${value.map(canonical).join(",")}]`;
20
+ if (value && typeof value === "object") {
21
+ return `{${Object.entries(value)
22
+ .filter(([, child]) => child !== undefined)
23
+ .sort(([left], [right]) => compareCodeUnits(left, right))
24
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
25
+ .join(",")}}`;
26
+ }
27
+ return JSON.stringify(value) ?? "null";
28
+ }
29
+ function sha256(value) {
30
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
31
+ }
32
+ function exactFields(value, fields) {
33
+ return Object.keys(value).sort(compareCodeUnits).join("\0") === [...fields].sort(compareCodeUnits).join("\0");
34
+ }
35
+ function boundedText(value, maximum, label) {
36
+ if (value === null)
37
+ return null;
38
+ if (typeof value !== "string")
39
+ throw new Error(`Project DNA host evidence ${label} is invalid`);
40
+ const normalized = value.replace(/\r\n?/g, "\n").trim();
41
+ if (!normalized || normalized.length > maximum || normalized.includes("\0")) {
42
+ throw new Error(`Project DNA host evidence ${label} is invalid`);
43
+ }
44
+ return normalized;
45
+ }
46
+ function dispositionMatchesKind(kind, disposition) {
47
+ return kind === "pull_request"
48
+ ? disposition === "merged"
49
+ : disposition === "approved" || disposition === "changes_requested" || disposition === "commented";
50
+ }
51
+ function sealItem(candidate) {
52
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)
53
+ || !exactFields(candidate, [
54
+ "kind", "ref", "disposition", "author_role", "title", "body",
55
+ ])
56
+ || !PROJECT_DNA_HOST_EVIDENCE_KINDS.includes(candidate.kind)
57
+ || !SOURCE_REF.test(candidate.ref)
58
+ || !PROJECT_DNA_HOST_EVIDENCE_DISPOSITIONS.includes(candidate.disposition)
59
+ || !PROJECT_DNA_HOST_EVIDENCE_AUTHOR_ROLES.includes(candidate.author_role)
60
+ || !dispositionMatchesKind(candidate.kind, candidate.disposition)) {
61
+ throw new Error("Project DNA host evidence candidate fields are invalid");
62
+ }
63
+ const unsigned = {
64
+ kind: candidate.kind,
65
+ ref: candidate.ref,
66
+ disposition: candidate.disposition,
67
+ author_role: candidate.author_role,
68
+ title: boundedText(candidate.title, MAX_TITLE, "title"),
69
+ body: boundedText(candidate.body, MAX_BODY, "body"),
70
+ };
71
+ if (unsigned.title === null && unsigned.body === null) {
72
+ throw new Error("Project DNA host evidence candidate has no observable content");
73
+ }
74
+ if (unsigned.kind === "pull_request" && unsigned.title === null) {
75
+ throw new Error("Project DNA pull-request evidence requires a title");
76
+ }
77
+ if (unsigned.kind === "review_comment" && (unsigned.title !== null || unsigned.body === null)) {
78
+ throw new Error("Project DNA review-comment evidence requires only a body");
79
+ }
80
+ const itemId = `pdnahi_${sha256(canonical(unsigned)).slice("sha256:".length, "sha256:".length + 20)}`;
81
+ const sealed = { ...unsigned, item_id: itemId };
82
+ return { ...sealed, content_hash: sha256(canonical(sealed)) };
83
+ }
84
+ /**
85
+ * Seal an explicitly authorized, repository-visible host evidence batch.
86
+ *
87
+ * This function never fetches a provider and never grants the host evidence
88
+ * policy authority. The caller owns source authorization and may pass only
89
+ * credential-free refs plus bounded repository-visible text.
90
+ */
91
+ export function sealProjectDnaHostEvidence(repositoryRevision, candidates) {
92
+ if (!GIT_OBJECT.test(repositoryRevision) || !Array.isArray(candidates)
93
+ || candidates.length < 1 || candidates.length > MAX_ITEMS) {
94
+ throw new Error("Project DNA host evidence set fields are invalid");
95
+ }
96
+ const items = candidates.map(sealItem)
97
+ .sort((left, right) => compareCodeUnits(left.item_id, right.item_id));
98
+ if (new Set(items.map((item) => item.item_id)).size !== items.length
99
+ || new Set(items.map((item) => item.ref)).size !== items.length) {
100
+ throw new Error("Project DNA host evidence items must have unique source identities");
101
+ }
102
+ const unsigned = {
103
+ schema: PROJECT_DNA_HOST_EVIDENCE_SCHEMA_VERSION,
104
+ repository_revision: repositoryRevision,
105
+ items,
106
+ };
107
+ const evidenceSetId = `pdnah_${sha256(canonical(unsigned)).slice("sha256:".length, "sha256:".length + 24)}`;
108
+ const sealed = { ...unsigned, evidence_set_id: evidenceSetId };
109
+ const result = { ...sealed, content_hash: sha256(canonical(sealed)) };
110
+ assertProjectDnaHostEvidence(result);
111
+ return result;
112
+ }
113
+ export function assertProjectDnaHostEvidence(value) {
114
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
115
+ throw new Error("Project DNA host evidence set is invalid");
116
+ }
117
+ const evidence = value;
118
+ if (!exactFields(value, [
119
+ "schema", "repository_revision", "items", "evidence_set_id", "content_hash",
120
+ ])
121
+ || evidence.schema !== PROJECT_DNA_HOST_EVIDENCE_SCHEMA_VERSION
122
+ || !GIT_OBJECT.test(evidence.repository_revision)
123
+ || !Array.isArray(evidence.items) || evidence.items.length < 1 || evidence.items.length > MAX_ITEMS
124
+ || !SET_ID.test(evidence.evidence_set_id) || !SHA256.test(evidence.content_hash)) {
125
+ throw new Error("Project DNA host evidence set fields are invalid");
126
+ }
127
+ const refs = new Set();
128
+ const ids = new Set();
129
+ let previous = "";
130
+ for (const item of evidence.items) {
131
+ if (!item || typeof item !== "object" || Array.isArray(item)
132
+ || !exactFields(item, [
133
+ "kind", "ref", "disposition", "author_role", "title", "body", "item_id", "content_hash",
134
+ ])
135
+ || !PROJECT_DNA_HOST_EVIDENCE_KINDS.includes(item.kind)
136
+ || !SOURCE_REF.test(item.ref)
137
+ || !PROJECT_DNA_HOST_EVIDENCE_DISPOSITIONS.includes(item.disposition)
138
+ || !PROJECT_DNA_HOST_EVIDENCE_AUTHOR_ROLES.includes(item.author_role)
139
+ || !dispositionMatchesKind(item.kind, item.disposition)
140
+ || !ITEM_ID.test(item.item_id) || !SHA256.test(item.content_hash)
141
+ || refs.has(item.ref) || ids.has(item.item_id) || (previous && compareCodeUnits(previous, item.item_id) >= 0)) {
142
+ throw new Error("Project DNA host evidence item fields are invalid");
143
+ }
144
+ const unsigned = {
145
+ kind: item.kind,
146
+ ref: item.ref,
147
+ disposition: item.disposition,
148
+ author_role: item.author_role,
149
+ title: boundedText(item.title, MAX_TITLE, "title"),
150
+ body: boundedText(item.body, MAX_BODY, "body"),
151
+ };
152
+ if (item.title !== unsigned.title || item.body !== unsigned.body
153
+ || (unsigned.title === null && unsigned.body === null)
154
+ || (unsigned.kind === "pull_request" && unsigned.title === null)
155
+ || (unsigned.kind === "review_comment" && (unsigned.title !== null || unsigned.body === null))) {
156
+ throw new Error("Project DNA host evidence item content is invalid");
157
+ }
158
+ const expectedId = `pdnahi_${sha256(canonical(unsigned)).slice("sha256:".length, "sha256:".length + 20)}`;
159
+ const sealed = { ...unsigned, item_id: item.item_id };
160
+ if (item.item_id !== expectedId || item.content_hash !== sha256(canonical(sealed))) {
161
+ throw new Error("Project DNA host evidence item seal is invalid");
162
+ }
163
+ refs.add(item.ref);
164
+ ids.add(item.item_id);
165
+ previous = item.item_id;
166
+ }
167
+ const { evidence_set_id: _setId, content_hash: _hash, ...base } = evidence;
168
+ const expectedSetId = `pdnah_${sha256(canonical(base)).slice("sha256:".length, "sha256:".length + 24)}`;
169
+ const sealed = { ...base, evidence_set_id: evidence.evidence_set_id };
170
+ if (evidence.evidence_set_id !== expectedSetId || evidence.content_hash !== sha256(canonical(sealed))) {
171
+ throw new Error("Project DNA host evidence set seal is invalid");
172
+ }
173
+ }
174
+ //# sourceMappingURL=projectDnaHostEvidence.js.map
@@ -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, type ProjectDnaArtifact, type ProjectDnaCategory, type ProjectDnaDiscoveryOptions, type ProjectDnaEvidence, type ProjectDnaEvidenceKind, type ProjectDnaMatch, type ProjectDnaMatchCheck, type ProjectDnaProfile, type ProjectDnaTrait, } from "./core/projectDna.js";
8
+ export { PROJECT_DNA_HOST_EVIDENCE_AUTHOR_ROLES, PROJECT_DNA_HOST_EVIDENCE_DISPOSITIONS, PROJECT_DNA_HOST_EVIDENCE_KINDS, PROJECT_DNA_HOST_EVIDENCE_SCHEMA_VERSION, assertProjectDnaHostEvidence, sealProjectDnaHostEvidence, type ProjectDnaHostEvidence, type ProjectDnaHostEvidenceAuthorRole, type ProjectDnaHostEvidenceCandidate, type ProjectDnaHostEvidenceDisposition, type ProjectDnaHostEvidenceItem, type ProjectDnaHostEvidenceKind, } from "./core/projectDnaHostEvidence.js";
9
+ export { PROJECT_DNA_DELTA_SCHEMA_VERSION, assertProjectDnaDelta, diffProjectDna, type ProjectDnaChangeKind, type ProjectDnaDelta, type ProjectDnaTraitChange, } from "./core/projectDnaDelta.js";
10
+ export { PROJECT_DNA_SUPPLEMENT_KIND, projectDnaDeliverySupplement, type ProjectDnaDeliverySupplement, } from "./core/projectDnaDelivery.js";
@@ -5,6 +5,7 @@
5
5
  * layout can evolve without changing the published contract entry point.
6
6
  */
7
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_HOST_EVIDENCE_AUTHOR_ROLES, PROJECT_DNA_HOST_EVIDENCE_DISPOSITIONS, PROJECT_DNA_HOST_EVIDENCE_KINDS, PROJECT_DNA_HOST_EVIDENCE_SCHEMA_VERSION, assertProjectDnaHostEvidence, sealProjectDnaHostEvidence, } from "./core/projectDnaHostEvidence.js";
8
9
  export { PROJECT_DNA_DELTA_SCHEMA_VERSION, assertProjectDnaDelta, diffProjectDna, } from "./core/projectDnaDelta.js";
9
10
  export { PROJECT_DNA_SUPPLEMENT_KIND, projectDnaDeliverySupplement, } from "./core/projectDnaDelivery.js";
10
11
  //# sourceMappingURL=projectDna.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.21.1",
3
+ "version": "1.22.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
@@ -27,6 +27,8 @@
27
27
  },
28
28
  "files": [
29
29
  "dist/**/*.js",
30
+ "dist/projectDna.d.ts",
31
+ "dist/core/projectDna*.d.ts",
30
32
  "server.json",
31
33
  "bench/constitution-exp03-v1.json",
32
34
  "tooling/competitive-watch.mjs",
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.21.1",
10
+ "version": "1.22.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.21.1",
16
+ "version": "1.22.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {