@davesheffer/hunch 1.20.3 → 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 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.20.3
177
+ npm i -g @davesheffer/hunch@1.21.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.20.3
192
+ npm i -g @davesheffer/hunch@1.21.0
193
193
  git pull
194
194
  hunch init
195
195
  hunch doctor
@@ -237,7 +237,7 @@ but stops automatic memory commits and pushes. As a team-coordinated rollback, r
237
237
  commit to stop discovery after teammates pull the revert. Existing machines retain their ignored
238
238
  local overlay until they are deliberately disconnected; do not delete the memory repo as part of a
239
239
  rollback. For this rollout, reinstall the previous published package with
240
- `npm i -g @davesheffer/hunch@1.20.2`; the release receipt resolves and records the verified rollback
240
+ `npm i -g @davesheffer/hunch@1.20.3`; the release receipt resolves and records the verified rollback
241
241
  target from the npm registry instead of trusting Git tags. Pause enforcement first as shown above,
242
242
  and keep every team client on the same release before resuming Matrix policy workflows.
243
243
 
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { Command } from "commander";
4
+ import { discoverProjectDna, evaluateProjectDnaMatch } from "../core/projectDna.js";
5
+ import { diffProjectDna } from "../core/projectDnaDelta.js";
6
+ import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
7
+ function gitRoot() {
8
+ try {
9
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
10
+ encoding: "utf8",
11
+ stdio: ["ignore", "pipe", "pipe"],
12
+ timeout: 5_000,
13
+ }).trim();
14
+ }
15
+ catch {
16
+ throw new Error("Project DNA must run inside a Git repository");
17
+ }
18
+ }
19
+ function printProfile(root, revision, json) {
20
+ const profile = discoverProjectDna(root, revision);
21
+ if (json) {
22
+ process.stdout.write(`${JSON.stringify(profile, null, 2)}\n`);
23
+ return;
24
+ }
25
+ process.stdout.write(`Project DNA ${profile.profile_id}\n`);
26
+ process.stdout.write(`revision: ${profile.repository_revision}\n`);
27
+ process.stdout.write(`history sample: ${profile.history_sample_count}\n`);
28
+ process.stdout.write(`committed convention files: ${profile.source_files.length ? profile.source_files.join(", ") : "none"}\n`);
29
+ if (!profile.traits.length) {
30
+ process.stdout.write("traits: none yet (insufficient evidence)\n");
31
+ return;
32
+ }
33
+ for (const trait of profile.traits) {
34
+ process.stdout.write(`- [${trait.category}] ${trait.claim} (${trait.confidence.toFixed(2)}; ${trait.id})\n`);
35
+ }
36
+ }
37
+ const program = new Command();
38
+ program
39
+ .name("hunch-dna")
40
+ .description("Derive and inspect Hunch Project DNA from exact committed repository evidence.")
41
+ .option("--revision <ref>", "Git revision to inspect", "HEAD")
42
+ .option("--json", "emit the sealed machine-readable profile")
43
+ .action((options) => {
44
+ printProfile(gitRoot(), options.revision, !!options.json);
45
+ });
46
+ program
47
+ .command("match")
48
+ .description("Score a commit/PR/issue/message against deterministic applicable DNA traits.")
49
+ .requiredOption("--kind <kind>", "commit | pull_request | issue | message")
50
+ .requiredOption("--title <title>", "artifact title/subject")
51
+ .option("--body <body>", "artifact body")
52
+ .option("--revision <ref>", "Git revision whose DNA applies", "HEAD")
53
+ .option("--json", "emit the sealed match contract")
54
+ .action((options) => {
55
+ if (!(new Set(["commit", "pull_request", "issue", "message"])).has(options.kind)) {
56
+ throw new Error("--kind must be commit, pull_request, issue, or message");
57
+ }
58
+ const artifact = {
59
+ kind: options.kind,
60
+ title: options.title,
61
+ ...(options.body !== undefined ? { body: options.body } : {}),
62
+ };
63
+ const globals = program.opts();
64
+ const revision = options.revision ?? globals.revision ?? "HEAD";
65
+ const json = options.json ?? globals.json ?? false;
66
+ const profile = discoverProjectDna(gitRoot(), revision);
67
+ const match = evaluateProjectDnaMatch(profile, artifact);
68
+ if (json)
69
+ process.stdout.write(`${JSON.stringify(match, null, 2)}\n`);
70
+ else {
71
+ process.stdout.write(`Project DNA match: ${match.score === null ? "n/a" : `${match.score}%`} (${match.applicable_checks} applicable checks)\n`);
72
+ for (const check of match.checks.filter((item) => item.applicable)) {
73
+ process.stdout.write(`- ${check.passed ? "PASS" : "FAIL"} ${check.key}: ${check.detail}\n`);
74
+ }
75
+ }
76
+ });
77
+ program
78
+ .command("context")
79
+ .description("Render the bounded advisory DNA supplement intended for Hunch delivery/context assembly.")
80
+ .option("--revision <ref>", "Git revision to inspect", "HEAD")
81
+ .option("--traits <count>", "maximum DNA traits in the orientation slice", "8")
82
+ .option("--json", "emit supplement JSON")
83
+ .action((options) => {
84
+ const globals = program.opts();
85
+ const revision = options.revision ?? globals.revision ?? "HEAD";
86
+ const json = options.json ?? globals.json ?? false;
87
+ const cap = Number(options.traits);
88
+ const supplement = projectDnaDeliverySupplement(discoverProjectDna(gitRoot(), revision), cap);
89
+ if (json)
90
+ process.stdout.write(`${JSON.stringify(supplement, null, 2)}\n`);
91
+ else
92
+ process.stdout.write(`${supplement?.text ?? "No evidence-backed Project DNA traits yet."}\n`);
93
+ });
94
+ program
95
+ .command("diff")
96
+ .description("Compare DNA at two exact revisions; reports observation drift without inferring causality.")
97
+ .argument("<from>", "older Git revision")
98
+ .argument("<to>", "newer Git revision")
99
+ .option("--json", "emit the sealed delta contract")
100
+ .action((from, to, options) => {
101
+ const root = gitRoot();
102
+ const delta = diffProjectDna(discoverProjectDna(root, from), discoverProjectDna(root, to));
103
+ const json = options.json ?? program.opts().json ?? false;
104
+ if (json)
105
+ process.stdout.write(`${JSON.stringify(delta, null, 2)}\n`);
106
+ else {
107
+ process.stdout.write(`Project DNA drift ${delta.delta_id}: ${delta.changed ? `${delta.changes.length} change(s)` : "no observed change"}\n`);
108
+ for (const change of delta.changes)
109
+ process.stdout.write(`- ${change.kind}: ${change.key}\n`);
110
+ }
111
+ });
112
+ program.parseAsync().catch((error) => {
113
+ process.stderr.write(`hunch-dna: ${error instanceof Error ? error.message : String(error)}\n`);
114
+ process.exitCode = 1;
115
+ });
116
+ //# sourceMappingURL=dna.js.map
package/dist/cli/index.js CHANGED
@@ -57,6 +57,9 @@ import { compileVerifiedEvidenceMap, formatVerifiedEvidenceMap } from "../core/e
57
57
  import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
58
58
  import { buildDeliveryEnvelope, DELIVERY_PROFILES } from "../core/delivery.js";
59
59
  import { deriveChangeIdentity } from "../core/changeIdentity.js";
60
+ import { discoverProjectDna, evaluateProjectDnaMatch } from "../core/projectDna.js";
61
+ import { diffProjectDna } from "../core/projectDnaDelta.js";
62
+ import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
60
63
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
61
64
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
62
65
  import { isHumanConfirmed } from "../core/strictgate.js";
@@ -2903,6 +2906,114 @@ landscapeCmd
2903
2906
  console.log(` hunch landscape adopt --ref ${discovery.sourceRevision} --expected ${discovery.discoveryHash} --all --reviewed-by <you>${discovery.issues.length ? " --acknowledge-issues" : ""}`);
2904
2907
  console.log("Or pass --candidate <hash...> to adopt an explicit subset; relationships require both endpoint resources.");
2905
2908
  });
2909
+ // ---- dna (read-only, exact-revision repository convention profile) -------
2910
+ const dnaCmd = program
2911
+ .command("dna")
2912
+ .description("Inspect evidence-backed Project DNA and evaluate repository-native artifacts without writing graph authority.");
2913
+ dnaCmd
2914
+ .command("inspect")
2915
+ .description("Derive a deterministic Project DNA profile from one exact Git revision. This command never writes memory.")
2916
+ .option("--ref <ref>", "Git commit/ref to inspect", "HEAD")
2917
+ .option("--json", "emit the complete machine-readable profile")
2918
+ .action((opts) => {
2919
+ try {
2920
+ const profile = discoverProjectDna(findRoot(), opts.ref);
2921
+ if (opts.json) {
2922
+ console.log(JSON.stringify(profile, null, 2));
2923
+ return;
2924
+ }
2925
+ console.log(`Project DNA at ${profile.repository_revision}`);
2926
+ console.log(`Profile: ${profile.profile_id} (${profile.content_hash})`);
2927
+ console.log(`Evidence: ${profile.history_sample_count} commit subject(s), ${profile.source_files.length} convention file(s)`);
2928
+ if (profile.source_files.length)
2929
+ console.log(`Sources: ${profile.source_files.join(", ")}`);
2930
+ console.log(`\nTRAITS (${profile.traits.length})`);
2931
+ for (const trait of profile.traits) {
2932
+ console.log(` ${trait.id} [${trait.category}/${trait.confidence.toFixed(3)}] ${trait.claim}`);
2933
+ console.log(` ${trait.evidence.map((evidence) => `${evidence.ref} @ ${evidence.revision.slice(0, 12)} (${evidence.content_hash})`).join("; ")}`);
2934
+ }
2935
+ console.log("\nAdvisory observation only; no trait was adopted into graph authority.");
2936
+ }
2937
+ catch (error) {
2938
+ fail(error instanceof Error ? error.message : String(error));
2939
+ }
2940
+ });
2941
+ dnaCmd
2942
+ .command("match")
2943
+ .description("Explainably score a commit, PR, issue, or message against deterministic checks in an exact-revision DNA profile.")
2944
+ .requiredOption("--kind <kind>", "artifact kind: commit, pull_request, issue, or message")
2945
+ .requiredOption("--title <title>", "artifact title or commit subject")
2946
+ .option("--body <body>", "artifact body (for example a PR description)")
2947
+ .option("--ref <ref>", "Git commit/ref whose DNA should be used", "HEAD")
2948
+ .option("--json", "emit the complete machine-readable match envelope")
2949
+ .action((opts) => {
2950
+ try {
2951
+ if (!["commit", "pull_request", "issue", "message"].includes(opts.kind)) {
2952
+ return fail("--kind must be commit, pull_request, issue, or message");
2953
+ }
2954
+ const profile = discoverProjectDna(findRoot(), opts.ref);
2955
+ const match = evaluateProjectDnaMatch(profile, {
2956
+ kind: opts.kind,
2957
+ title: opts.title,
2958
+ body: opts.body,
2959
+ });
2960
+ if (opts.json) {
2961
+ console.log(JSON.stringify(match, null, 2));
2962
+ return;
2963
+ }
2964
+ console.log(`Project Match: ${match.score === null ? "not enough deterministic checks" : `${match.score.toFixed(1)}/100`}`);
2965
+ console.log(`Profile: ${match.profile_id} @ ${match.repository_revision}`);
2966
+ for (const check of match.checks.filter((candidate) => candidate.applicable)) {
2967
+ console.log(` ${check.passed ? "PASS" : "FAIL"} ${check.key} — ${check.detail}`);
2968
+ }
2969
+ console.log("Advisory only; the score does not grant or change enforcement authority.");
2970
+ }
2971
+ catch (error) {
2972
+ fail(error instanceof Error ? error.message : String(error));
2973
+ }
2974
+ });
2975
+ dnaCmd
2976
+ .command("context")
2977
+ .description("Render the bounded advisory Project DNA supplement used by normal Hunch context delivery.")
2978
+ .option("--ref <ref>", "Git commit/ref to inspect", "HEAD")
2979
+ .option("--traits <count>", "maximum traits in the orientation slice", "8")
2980
+ .option("--json", "emit the complete machine-readable supplement")
2981
+ .action((opts) => {
2982
+ try {
2983
+ const traitCap = Number(opts.traits);
2984
+ const supplement = projectDnaDeliverySupplement(discoverProjectDna(findRoot(), opts.ref), traitCap);
2985
+ if (opts.json)
2986
+ console.log(JSON.stringify(supplement, null, 2));
2987
+ else
2988
+ console.log(supplement?.text ?? "No evidence-backed Project DNA traits yet.");
2989
+ }
2990
+ catch (error) {
2991
+ fail(error instanceof Error ? error.message : String(error));
2992
+ }
2993
+ });
2994
+ dnaCmd
2995
+ .command("diff")
2996
+ .description("Compare two exact-revision DNA profiles without rewriting either snapshot.")
2997
+ .argument("<from>", "older Git revision")
2998
+ .argument("<to>", "newer Git revision")
2999
+ .option("--json", "emit the complete sealed delta")
3000
+ .action((from, to, opts) => {
3001
+ try {
3002
+ const root = findRoot();
3003
+ const delta = diffProjectDna(discoverProjectDna(root, from), discoverProjectDna(root, to));
3004
+ if (opts.json) {
3005
+ console.log(JSON.stringify(delta, null, 2));
3006
+ return;
3007
+ }
3008
+ console.log(`Project DNA drift ${delta.delta_id}: ${delta.changed ? `${delta.changes.length} change(s)` : "no observed change"}`);
3009
+ for (const change of delta.changes)
3010
+ console.log(` ${change.kind} ${change.key}`);
3011
+ console.log("Observation only; no Project DNA profile or graph authority was rewritten.");
3012
+ }
3013
+ catch (error) {
3014
+ fail(error instanceof Error ? error.message : String(error));
3015
+ }
3016
+ });
2906
3017
  landscapeCmd
2907
3018
  .command("adopt")
2908
3019
  .description("Human-confirm a reviewed candidate set and write only those exact records through Hunch's normal graph boundary.")
@@ -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
@@ -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
@@ -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
@@ -882,14 +1032,17 @@ export function buildServerWithRootControl(initialRoot) {
882
1032
  };
883
1033
  const envelope = buildDeliveryEnvelope(fallback, {
884
1034
  ...options,
885
- supplements: hits
886
- .filter((hit) => !["constraints", "decisions", "bugs", "findings"].includes(hit.kind))
887
- .map((hit, index) => ({
888
- id: hit.ref,
889
- kind: `search-${hit.kind}`,
890
- text: `${hit.ref} — ${hit.title}: ${hit.snippet}`,
891
- priority: 100 - index,
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.20.3",
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",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://hunch-pi.vercel.app",
10
- "version": "1.20.3",
10
+ "version": "1.21.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.20.3",
16
+ "version": "1.21.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {