@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 +3 -3
- package/dist/cli/dna.js +116 -0
- package/dist/cli/index.js +111 -0
- package/dist/core/projectDna.js +478 -0
- package/dist/core/projectDnaDelivery.js +54 -0
- package/dist/core/projectDnaDelta.js +159 -0
- package/dist/mcp/server.js +161 -8
- package/dist/projectDna.js +10 -0
- package/package.json +9 -1
- package/server.json +2 -2
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.
|
|
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.
|
|
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.
|
|
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
|
|
package/dist/cli/dna.js
ADDED
|
@@ -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
|
package/dist/mcp/server.js
CHANGED
|
@@ -27,6 +27,9 @@ import { compileVerifiedEvidenceMap, EvidenceExecutionSchema, EvidenceInterventi
|
|
|
27
27
|
import { collectCorrectionStageSources } from "../extractors/correctionSources.js";
|
|
28
28
|
import { buildDeliveryEnvelope, DELIVERY_PROFILE_POLICY_VERSION, DELIVERY_PROFILES, } from "../core/delivery.js";
|
|
29
29
|
import { CHANGE_IDENTITY_ALGORITHM, CHANGE_IDENTITY_SCHEMA_VERSION, deriveChangeIdentity, } from "../core/changeIdentity.js";
|
|
30
|
+
import { PROJECT_DNA_CATEGORIES, PROJECT_DNA_MATCH_SCHEMA_VERSION, PROJECT_DNA_SCHEMA_VERSION, discoverProjectDna, evaluateProjectDnaMatch, } from "../core/projectDna.js";
|
|
31
|
+
import { PROJECT_DNA_DELTA_SCHEMA_VERSION, diffProjectDna } from "../core/projectDnaDelta.js";
|
|
32
|
+
import { projectDnaDeliverySupplement } from "../core/projectDnaDelivery.js";
|
|
30
33
|
import { armExecutionObligations, loadPipelineState, savePipelineState } from "../core/pipeline.js";
|
|
31
34
|
import { recordServed } from "../core/served.js";
|
|
32
35
|
import { EdgeSchema, ResourceSchema } from "../core/types.js";
|
|
@@ -250,6 +253,73 @@ const CHANGE_IDENTITY_OUTPUT_SCHEMA = z.object({
|
|
|
250
253
|
paths_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
251
254
|
content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
252
255
|
});
|
|
256
|
+
const PROJECT_DNA_EVIDENCE_SCHEMA = z.object({
|
|
257
|
+
kind: z.enum(["git-history", "committed-file"]),
|
|
258
|
+
ref: z.string(),
|
|
259
|
+
revision: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
260
|
+
content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
261
|
+
sample_count: z.number().int().positive(),
|
|
262
|
+
provenance: z.literal("committed-repository"),
|
|
263
|
+
visibility: z.literal("repository"),
|
|
264
|
+
});
|
|
265
|
+
const PROJECT_DNA_PROFILE_OUTPUT_SCHEMA = z.object({
|
|
266
|
+
schema: z.literal(PROJECT_DNA_SCHEMA_VERSION),
|
|
267
|
+
profile_id: z.string().regex(/^pdna_[a-f0-9]{24}$/),
|
|
268
|
+
repository_id: z.string().regex(/^pdnar_[a-f0-9]{24}$/),
|
|
269
|
+
repository_revision: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
270
|
+
history_sample_count: z.number().int().nonnegative(),
|
|
271
|
+
source_files: z.array(z.string()),
|
|
272
|
+
traits: z.array(z.object({
|
|
273
|
+
id: z.string().regex(/^pdnat_[a-f0-9]{20}$/),
|
|
274
|
+
category: z.enum(PROJECT_DNA_CATEGORIES),
|
|
275
|
+
key: z.string(),
|
|
276
|
+
claim: z.string(),
|
|
277
|
+
confidence: z.number().min(0).max(1),
|
|
278
|
+
observation_state: z.literal("observed"),
|
|
279
|
+
freshness: z.literal("current"),
|
|
280
|
+
contradiction: z.literal("none"),
|
|
281
|
+
evidence: z.array(PROJECT_DNA_EVIDENCE_SCHEMA),
|
|
282
|
+
})),
|
|
283
|
+
content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
284
|
+
});
|
|
285
|
+
const PROJECT_DNA_MATCH_OUTPUT_SCHEMA = z.object({
|
|
286
|
+
schema: z.literal(PROJECT_DNA_MATCH_SCHEMA_VERSION),
|
|
287
|
+
match_id: z.string().regex(/^pdnam_[a-f0-9]{24}$/),
|
|
288
|
+
profile_id: z.string().regex(/^pdna_[a-f0-9]{24}$/),
|
|
289
|
+
repository_id: z.string().regex(/^pdnar_[a-f0-9]{24}$/),
|
|
290
|
+
repository_revision: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
291
|
+
artifact_kind: z.enum(["commit", "pull_request", "issue", "message"]),
|
|
292
|
+
score: z.number().min(0).max(100).nullable(),
|
|
293
|
+
applicable_checks: z.number().int().nonnegative(),
|
|
294
|
+
checks: z.array(z.object({
|
|
295
|
+
trait_id: z.string().regex(/^pdnat_[a-f0-9]{20}$/),
|
|
296
|
+
key: z.string(),
|
|
297
|
+
applicable: z.boolean(),
|
|
298
|
+
passed: z.boolean().nullable(),
|
|
299
|
+
weight: z.number().int().positive(),
|
|
300
|
+
detail: z.string(),
|
|
301
|
+
})),
|
|
302
|
+
content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
303
|
+
});
|
|
304
|
+
const PROJECT_DNA_DELTA_OUTPUT_SCHEMA = z.object({
|
|
305
|
+
schema: z.literal(PROJECT_DNA_DELTA_SCHEMA_VERSION),
|
|
306
|
+
delta_id: z.string().regex(/^pdnad_[a-f0-9]{24}$/),
|
|
307
|
+
repository_id: z.string().regex(/^pdnar_[a-f0-9]{24}$/),
|
|
308
|
+
from_profile_id: z.string().regex(/^pdna_[a-f0-9]{24}$/),
|
|
309
|
+
to_profile_id: z.string().regex(/^pdna_[a-f0-9]{24}$/),
|
|
310
|
+
from_revision: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
311
|
+
to_revision: z.string().regex(/^[a-f0-9]{40,64}$/),
|
|
312
|
+
changes: z.array(z.object({
|
|
313
|
+
key: z.string(),
|
|
314
|
+
kind: z.enum(["added", "removed", "evidence_changed", "confidence_changed"]),
|
|
315
|
+
before_trait_id: z.string().regex(/^pdnat_[a-f0-9]{20}$/).nullable(),
|
|
316
|
+
after_trait_id: z.string().regex(/^pdnat_[a-f0-9]{20}$/).nullable(),
|
|
317
|
+
before_confidence: z.number().min(0).max(1).nullable(),
|
|
318
|
+
after_confidence: z.number().min(0).max(1).nullable(),
|
|
319
|
+
})),
|
|
320
|
+
changed: z.boolean(),
|
|
321
|
+
content_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
|
322
|
+
});
|
|
253
323
|
/** Return the same human-readable brief older clients consume plus the exact
|
|
254
324
|
* machine-readable envelope. Receipt recording is deliberately best-effort:
|
|
255
325
|
* recordServed never throws, so telemetry can never cost a delivery. */
|
|
@@ -835,6 +905,76 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
835
905
|
return err(error.message);
|
|
836
906
|
}
|
|
837
907
|
});
|
|
908
|
+
// -- hunch_project_dna ----------------------------------------------------
|
|
909
|
+
server.registerTool("hunch_project_dna", {
|
|
910
|
+
title: "Inspect this repository's evidence-backed Project DNA",
|
|
911
|
+
description: "Derive a bounded, deterministic profile of how this repository communicates, reviews, and builds from one exact committed revision. Read-only: observations remain advisory and are never adopted into graph authority automatically.",
|
|
912
|
+
inputSchema: {
|
|
913
|
+
ref: z.string().min(1).max(1_024).optional().describe("Git commit/ref to inspect (default HEAD)."),
|
|
914
|
+
cwd: cwdHintField,
|
|
915
|
+
},
|
|
916
|
+
outputSchema: PROJECT_DNA_PROFILE_OUTPUT_SCHEMA,
|
|
917
|
+
}, async ({ ref }) => {
|
|
918
|
+
try {
|
|
919
|
+
const profile = PROJECT_DNA_PROFILE_OUTPUT_SCHEMA.parse(discoverProjectDna(root, ref ?? "HEAD"));
|
|
920
|
+
const headline = `${profile.profile_id} at ${profile.repository_revision}: ${profile.traits.length} evidence-backed trait(s) from ${profile.history_sample_count} commit subject(s) and ${profile.source_files.length} convention file(s). Advisory observation only.`;
|
|
921
|
+
return { content: [{ type: "text", text: headline }], structuredContent: profile };
|
|
922
|
+
}
|
|
923
|
+
catch (error) {
|
|
924
|
+
return err(error.message);
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
// -- hunch_project_match --------------------------------------------------
|
|
928
|
+
server.registerTool("hunch_project_match", {
|
|
929
|
+
title: "Evaluate whether an artifact matches this repository's Project DNA",
|
|
930
|
+
description: "Explainably score a commit subject, PR, issue, or message using only deterministic checks supported by an exact-revision Project DNA profile. Advisory: never changes policy or enforcement authority.",
|
|
931
|
+
inputSchema: {
|
|
932
|
+
kind: z.enum(["commit", "pull_request", "issue", "message"]),
|
|
933
|
+
title: z.string().min(1).max(1_000),
|
|
934
|
+
body: z.string().max(20_000).optional(),
|
|
935
|
+
ref: z.string().min(1).max(1_024).optional().describe("Git commit/ref whose DNA should be used (default HEAD)."),
|
|
936
|
+
cwd: cwdHintField,
|
|
937
|
+
},
|
|
938
|
+
outputSchema: PROJECT_DNA_MATCH_OUTPUT_SCHEMA,
|
|
939
|
+
}, async ({ kind, title, body, ref }) => {
|
|
940
|
+
try {
|
|
941
|
+
const profile = discoverProjectDna(root, ref ?? "HEAD");
|
|
942
|
+
const match = PROJECT_DNA_MATCH_OUTPUT_SCHEMA.parse(evaluateProjectDnaMatch(profile, { kind, title, body }));
|
|
943
|
+
const failed = match.checks.filter((check) => check.applicable && !check.passed).map((check) => check.key);
|
|
944
|
+
const headline = match.score === null
|
|
945
|
+
? `${match.match_id}: no deterministic DNA checks apply to this artifact.`
|
|
946
|
+
: `${match.match_id}: Project Match ${match.score.toFixed(1)}/100 across ${match.applicable_checks} check(s)${failed.length ? `; mismatches: ${failed.join(", ")}` : ""}. Advisory only.`;
|
|
947
|
+
return { content: [{ type: "text", text: headline }], structuredContent: match };
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
return err(error.message);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
// -- hunch_project_dna_delta ---------------------------------------------
|
|
954
|
+
server.registerTool("hunch_project_dna_delta", {
|
|
955
|
+
title: "Compare Project DNA across two exact repository revisions",
|
|
956
|
+
description: "Return a sealed, explainable delta between two immutable Project DNA profiles. Read-only: reports observation drift and never rewrites history or graph authority.",
|
|
957
|
+
inputSchema: {
|
|
958
|
+
from_ref: z.string().min(1).max(1_024).describe("Older Git commit/ref."),
|
|
959
|
+
to_ref: z.string().min(1).max(1_024).describe("Newer Git commit/ref."),
|
|
960
|
+
cwd: cwdHintField,
|
|
961
|
+
},
|
|
962
|
+
outputSchema: PROJECT_DNA_DELTA_OUTPUT_SCHEMA,
|
|
963
|
+
}, async ({ from_ref, to_ref }) => {
|
|
964
|
+
try {
|
|
965
|
+
const delta = PROJECT_DNA_DELTA_OUTPUT_SCHEMA.parse(diffProjectDna(discoverProjectDna(root, from_ref), discoverProjectDna(root, to_ref)));
|
|
966
|
+
return {
|
|
967
|
+
content: [{
|
|
968
|
+
type: "text",
|
|
969
|
+
text: `${delta.delta_id}: ${delta.changed ? `${delta.changes.length} observed DNA change(s)` : "no observed DNA change"} from ${delta.from_revision} to ${delta.to_revision}. Advisory only.`,
|
|
970
|
+
}],
|
|
971
|
+
structuredContent: delta,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
catch (error) {
|
|
975
|
+
return err(error.message);
|
|
976
|
+
}
|
|
977
|
+
});
|
|
838
978
|
// -- hunch_context (surgical retrieval) -----------------------------------
|
|
839
979
|
server.registerTool("hunch_context", {
|
|
840
980
|
title: "Assemble the minimal relevant Hunch slice for a task",
|
|
@@ -851,6 +991,15 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
851
991
|
if (as_of && !asOf)
|
|
852
992
|
return err(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
853
993
|
const ctx = store.assembleContext(target, budget_tokens ?? 1500, { asOf });
|
|
994
|
+
let dnaSupplement = null;
|
|
995
|
+
try {
|
|
996
|
+
dnaSupplement = projectDnaDeliverySupplement(discoverProjectDna(root, as_of ?? "HEAD"));
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
// Context retrieval must keep its existing graceful behavior when the
|
|
1000
|
+
// Git checkout cannot provide DNA; the dedicated DNA tool reports the
|
|
1001
|
+
// exact derivation error when a caller needs diagnostics.
|
|
1002
|
+
}
|
|
854
1003
|
const options = {
|
|
855
1004
|
root,
|
|
856
1005
|
symbols: store.recs("symbols"),
|
|
@@ -858,6 +1007,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
858
1007
|
decisionCorpus: store.recs("decisions"),
|
|
859
1008
|
historical: !!asOf,
|
|
860
1009
|
profile: profile ?? "builder",
|
|
1010
|
+
supplements: dnaSupplement ? [dnaSupplement] : [],
|
|
861
1011
|
};
|
|
862
1012
|
// Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
|
|
863
1013
|
// used to return an empty brief while the graph held the answer — fall back to
|
|
@@ -882,14 +1032,17 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
882
1032
|
};
|
|
883
1033
|
const envelope = buildDeliveryEnvelope(fallback, {
|
|
884
1034
|
...options,
|
|
885
|
-
supplements:
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1035
|
+
supplements: [
|
|
1036
|
+
...(dnaSupplement ? [dnaSupplement] : []),
|
|
1037
|
+
...hits
|
|
1038
|
+
.filter((hit) => !["constraints", "decisions", "bugs", "findings"].includes(hit.kind))
|
|
1039
|
+
.map((hit, index) => ({
|
|
1040
|
+
id: hit.ref,
|
|
1041
|
+
kind: `search-${hit.kind}`,
|
|
1042
|
+
text: `${hit.ref} — ${hit.title}: ${hit.snippet}`,
|
|
1043
|
+
priority: 100 - index,
|
|
1044
|
+
})),
|
|
1045
|
+
],
|
|
893
1046
|
});
|
|
894
1047
|
return deliveredContext(root, target, envelope, extra.sessionId);
|
|
895
1048
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable public programmatic surface for the Project DNA Engine.
|
|
3
|
+
*
|
|
4
|
+
* Keep transport/orchestration consumers on this barrel so internal core file
|
|
5
|
+
* layout can evolve without changing the published contract entry point.
|
|
6
|
+
*/
|
|
7
|
+
export { PROJECT_DNA_CATEGORIES, PROJECT_DNA_MATCH_SCHEMA_VERSION, PROJECT_DNA_SCHEMA_VERSION, assertProjectDnaMatch, assertProjectDnaProfile, discoverProjectDna, evaluateProjectDnaMatch, } from "./core/projectDna.js";
|
|
8
|
+
export { PROJECT_DNA_DELTA_SCHEMA_VERSION, assertProjectDnaDelta, diffProjectDna, } from "./core/projectDnaDelta.js";
|
|
9
|
+
export { PROJECT_DNA_SUPPLEMENT_KIND, projectDnaDeliverySupplement, } from "./core/projectDnaDelivery.js";
|
|
10
|
+
//# sourceMappingURL=projectDna.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
"bin": {
|
|
18
18
|
"hunch": "dist/cli/index.js"
|
|
19
19
|
},
|
|
20
|
+
"exports": {
|
|
21
|
+
"./project-dna": {
|
|
22
|
+
"types": "./dist/projectDna.d.ts",
|
|
23
|
+
"default": "./dist/projectDna.js"
|
|
24
|
+
},
|
|
25
|
+
"./dist/*": "./dist/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
20
28
|
"files": [
|
|
21
29
|
"dist/**/*.js",
|
|
22
30
|
"server.json",
|
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.
|
|
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.
|
|
16
|
+
"version": "1.21.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|