@polymorphism-tech/morph-spec 4.19.0 → 4.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 +2 -2
- package/bin/morph-spec.js +11 -0
- package/bin/task-manager.js +35 -10
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents/architecture/architecture-expert.md +12 -0
- package/framework/agents/architecture/packages-validator.md +12 -0
- package/framework/agents/architecture/security-expert.md +12 -0
- package/framework/agents/frontend/design-system-validator.md +12 -0
- package/framework/agents/frontend/nextjs-component-validator.md +12 -0
- package/framework/hooks/claude-code/post-tool-use/dispatch.js +20 -3
- package/framework/hooks/claude-code/post-tool-use/validator-feedback.js +60 -11
- package/framework/rubrics/backend.json +36 -0
- package/framework/rubrics/default.json +36 -0
- package/framework/rubrics/frontend.json +36 -0
- package/framework/skills/level-0-meta/mcp-registry.json +21 -8
- package/framework/skills/level-0-meta/morph-frontend-review/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/dashboard/dashboard.js +53 -0
- package/src/commands/mcp/mcp-setup.js +99 -2
- package/src/dashboard/app/App.jsx +31 -0
- package/src/dashboard/app/components/DetailPanel.jsx +11 -0
- package/src/dashboard/app/components/FlowGraph.jsx +17 -0
- package/src/dashboard/app/components/SearchFilter.jsx +22 -0
- package/src/dashboard/app/components/Sidebar.jsx +45 -0
- package/src/dashboard/app/hooks/useDetailPanel.js +15 -0
- package/src/dashboard/app/main.jsx +13 -0
- package/src/dashboard/app/pages/AgentMap.jsx +371 -0
- package/src/dashboard/app/pages/HookInspector.jsx +418 -0
- package/src/dashboard/app/pages/LiveState.jsx +239 -0
- package/src/dashboard/app/pages/PhasePipeline.jsx +410 -0
- package/src/dashboard/app/pages/RulesMap.jsx +273 -0
- package/src/dashboard/app/pages/SkillsLibrary.jsx +367 -0
- package/src/dashboard/app/pages/Standards.jsx +332 -0
- package/src/dashboard/app/pages/Workflows.jsx +346 -0
- package/src/dashboard/app/styles/index.css +47 -0
- package/src/dashboard/index.html +12 -0
- package/src/dashboard/package.json +24 -0
- package/src/dashboard/scanner/agents-scanner.js +35 -0
- package/src/dashboard/scanner/hooks-scanner.js +41 -0
- package/src/dashboard/scanner/index.js +21 -0
- package/src/dashboard/scanner/phases-scanner.js +15 -0
- package/src/dashboard/scanner/rubrics-scanner.js +12 -0
- package/src/dashboard/scanner/rules-scanner.js +37 -0
- package/src/dashboard/scanner/skills-scanner.js +43 -0
- package/src/dashboard/scanner/squads-scanner.js +13 -0
- package/src/dashboard/scanner/standards-scanner.js +26 -0
- package/src/dashboard/scanner/state-scanner.js +8 -0
- package/src/dashboard/scanner/workflows-scanner.js +18 -0
- package/src/dashboard/vite-plugin-morph-data.js +17 -0
- package/src/dashboard/vite.config.js +24 -0
- package/src/lib/checkpoints/checkpoint-hooks.js +2 -1
- package/src/lib/detectors/index.js +1 -1
- package/src/lib/generators/recap-generator.js +15 -0
- package/src/lib/installers/mcp-installer.js +140 -1
- package/src/lib/validators/score-aggregator.js +244 -0
- package/src/lib/validators/validation-runner.js +44 -0
- package/src/scripts/setup-infra.js +8 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function scanStandards(frameworkDir, agents) {
|
|
5
|
+
const standardsJsonPath = join(frameworkDir, 'standards', 'STANDARDS.json');
|
|
6
|
+
if (!existsSync(standardsJsonPath)) return { version: 'unknown', list: [] };
|
|
7
|
+
const sJson = JSON.parse(readFileSync(standardsJsonPath, 'utf8'));
|
|
8
|
+
const refMap = {};
|
|
9
|
+
for (const agent of agents) {
|
|
10
|
+
for (const std of agent.standards) {
|
|
11
|
+
if (!refMap[std.id]) refMap[std.id] = [];
|
|
12
|
+
refMap[std.id].push({ agentId: agent.id, scope: std.scope, priority: std.priority });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const list = sJson.standards.map(std => {
|
|
16
|
+
const mdPath = join(frameworkDir, 'standards', std.path);
|
|
17
|
+
return {
|
|
18
|
+
id: std.id, name: std.name, path: std.path, category: std.category,
|
|
19
|
+
subcategory: std.subcategory || null, tags: std.tags || [],
|
|
20
|
+
digest: std.digest || '', stacks: std.stacks || [],
|
|
21
|
+
content: existsSync(mdPath) ? readFileSync(mdPath, 'utf8') : '',
|
|
22
|
+
referencedBy: refMap[std.id] || [],
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
return { version: sJson.version, list };
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function scanState(projectDir) {
|
|
5
|
+
const statePath = join(projectDir, '.morph', 'state.json');
|
|
6
|
+
if (!existsSync(statePath)) return null;
|
|
7
|
+
try { return JSON.parse(readFileSync(statePath, 'utf8')); } catch { return null; }
|
|
8
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function scanWorkflows(frameworkDir) {
|
|
5
|
+
const dir = join(frameworkDir, 'workflows', 'configs');
|
|
6
|
+
if (!existsSync(dir)) return [];
|
|
7
|
+
return readdirSync(dir).filter(f => f.endsWith('.json')).map(f => {
|
|
8
|
+
const d = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
9
|
+
return {
|
|
10
|
+
id: d.id, name: d.name, description: d.description || '',
|
|
11
|
+
criteria: d.criteria || {}, keywords: d.keywords || [],
|
|
12
|
+
phases: d.phases || { run: [], skip: [] },
|
|
13
|
+
pausePoints: d.pausePoints || [],
|
|
14
|
+
outputs: d.outputs || { required: [], optional: [], skip: [] },
|
|
15
|
+
priority: d.priority || 0,
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { scanAll } from './scanner/index.js';
|
|
2
|
+
|
|
3
|
+
const VIRTUAL_MODULE_ID = 'virtual:morph-data';
|
|
4
|
+
const RESOLVED_VIRTUAL_ID = '\0' + VIRTUAL_MODULE_ID;
|
|
5
|
+
|
|
6
|
+
export default function morphDataPlugin(frameworkDir, projectDir) {
|
|
7
|
+
return {
|
|
8
|
+
name: 'vite-plugin-morph-data',
|
|
9
|
+
resolveId(id) { if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_ID; },
|
|
10
|
+
load(id) {
|
|
11
|
+
if (id === RESOLVED_VIRTUAL_ID) {
|
|
12
|
+
const data = scanAll(frameworkDir, projectDir);
|
|
13
|
+
return `export default ${JSON.stringify(data)}`;
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
+
import morphDataPlugin from './vite-plugin-morph-data.js';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname, resolve } from 'path';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
|
|
11
|
+
const frameworkDir = resolve(__dirname, '..', '..', 'framework');
|
|
12
|
+
const projectDir = process.env.MORPH_PROJECT_DIR || process.cwd();
|
|
13
|
+
|
|
14
|
+
export default defineConfig({
|
|
15
|
+
root: __dirname,
|
|
16
|
+
plugins: [
|
|
17
|
+
react(),
|
|
18
|
+
tailwindcss(),
|
|
19
|
+
morphDataPlugin(frameworkDir, projectDir),
|
|
20
|
+
],
|
|
21
|
+
server: {
|
|
22
|
+
port: 3200,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -48,7 +48,8 @@ export async function runCheckpointHooks(featureName, checkpointNum, opts = {})
|
|
|
48
48
|
passed: valResult.passed,
|
|
49
49
|
errors: valResult.errors,
|
|
50
50
|
warnings: valResult.warnings,
|
|
51
|
-
details: valResult.results
|
|
51
|
+
details: valResult.results,
|
|
52
|
+
scores: valResult.scores || null
|
|
52
53
|
};
|
|
53
54
|
totalErrors += valResult.errors;
|
|
54
55
|
totalWarnings += valResult.warnings;
|
|
@@ -30,7 +30,7 @@ export function detectProject(targetPath, opts = {}) {
|
|
|
30
30
|
try {
|
|
31
31
|
// ── Package.json analysis ──────────────────────────────────────────────
|
|
32
32
|
// Collect deps from root and common monorepo subfolders
|
|
33
|
-
const pkgLocations = ['.', 'src/frontend', 'frontend', 'apps/web', 'packages/app'];
|
|
33
|
+
const pkgLocations = ['.', 'src/frontend', 'src/web', 'frontend', 'apps/web', 'packages/app'];
|
|
34
34
|
const allDeps = {};
|
|
35
35
|
let rootPkg = null;
|
|
36
36
|
let scripts = [];
|
|
@@ -179,6 +179,21 @@ function buildRecapMarkdown(data) {
|
|
|
179
179
|
md += `> Verify actual build status with the commands in the section below.\n\n`;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// Score Breakdown (when rubric scoring is available)
|
|
183
|
+
if (validationResult.scores) {
|
|
184
|
+
const scores = validationResult.scores;
|
|
185
|
+
md += `### Quality Score: ${scores.score}/10\n\n`;
|
|
186
|
+
md += `| Category | Score | Weight | Status |\n|----------|-------|--------|--------|\n`;
|
|
187
|
+
const approveThreshold = scores.approveThreshold ?? 8.0;
|
|
188
|
+
for (const [category, data] of Object.entries(scores.breakdown)) {
|
|
189
|
+
if (category === '_unmapped') continue;
|
|
190
|
+
const pct = Math.round(data.weight * 100);
|
|
191
|
+
const status = data.score >= approveThreshold ? 'Pass' : data.score >= 6 ? 'Needs work' : 'Critical';
|
|
192
|
+
md += `| ${category} | ${Math.round(data.score * 10) / 10}/10 | ${pct}% | ${status} |\n`;
|
|
193
|
+
}
|
|
194
|
+
md += `\n`;
|
|
195
|
+
}
|
|
196
|
+
|
|
182
197
|
// Suggested Quality Verification
|
|
183
198
|
const agents = agentsSummary;
|
|
184
199
|
const hasNextjs = agents.some(a => ['nextjs-expert', 'css-specialist', 'ui-designer'].includes(a));
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { join } from 'path';
|
|
10
|
-
import { readFileSync, existsSync } from 'fs';
|
|
10
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
11
11
|
import { execSync } from 'child_process';
|
|
12
12
|
import { installMcpServers } from '../../utils/claude-settings-manager.js';
|
|
13
13
|
|
|
@@ -173,6 +173,145 @@ export function installCompanionSkills(mcpsInstalled, exec = execSync) {
|
|
|
173
173
|
return results;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Generate the Stitch OAuth proxy template script.
|
|
178
|
+
* @param {string} gcpProject - GCP project ID for x-goog-user-project header
|
|
179
|
+
* @returns {string} CJS proxy script content
|
|
180
|
+
*/
|
|
181
|
+
export function getStitchProxyTemplate(gcpProject) {
|
|
182
|
+
return `/**
|
|
183
|
+
* Stitch MCP OAuth Proxy
|
|
184
|
+
* Generated by morph-spec mcp setup stitch
|
|
185
|
+
*
|
|
186
|
+
* Bridges stdio JSON-RPC to https://stitch.googleapis.com/mcp
|
|
187
|
+
* using gcloud application-default credentials (OAuth2 bearer token).
|
|
188
|
+
*/
|
|
189
|
+
const { execSync } = require('child_process');
|
|
190
|
+
const https = require('https');
|
|
191
|
+
|
|
192
|
+
const GCP_PROJECT = '${gcpProject}';
|
|
193
|
+
const STITCH_HOST = 'stitch.googleapis.com';
|
|
194
|
+
const STITCH_PATH = '/mcp';
|
|
195
|
+
const TOKEN_TTL_MS = 50 * 60 * 1000; // 50 minutes
|
|
196
|
+
|
|
197
|
+
let cachedToken = null;
|
|
198
|
+
let tokenExpiresAt = 0;
|
|
199
|
+
|
|
200
|
+
function getAccessToken() {
|
|
201
|
+
if (cachedToken && Date.now() < tokenExpiresAt) return cachedToken;
|
|
202
|
+
try {
|
|
203
|
+
cachedToken = execSync('gcloud auth application-default print-access-token', {
|
|
204
|
+
encoding: 'utf-8',
|
|
205
|
+
timeout: 10000,
|
|
206
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
207
|
+
}).trim();
|
|
208
|
+
tokenExpiresAt = Date.now() + TOKEN_TTL_MS;
|
|
209
|
+
return cachedToken;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
process.stderr.write('[stitch-proxy] Failed to get access token. Run: gcloud auth application-default login\\n');
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function clearTokenCache() {
|
|
217
|
+
cachedToken = null;
|
|
218
|
+
tokenExpiresAt = 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sendRequest(body) {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const token = getAccessToken();
|
|
224
|
+
const data = JSON.stringify(body);
|
|
225
|
+
const options = {
|
|
226
|
+
hostname: STITCH_HOST,
|
|
227
|
+
path: STITCH_PATH,
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: {
|
|
230
|
+
'Content-Type': 'application/json',
|
|
231
|
+
'Content-Length': Buffer.byteLength(data),
|
|
232
|
+
'Authorization': \`Bearer \${token}\`,
|
|
233
|
+
'x-goog-user-project': GCP_PROJECT,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const req = https.request(options, (res) => {
|
|
238
|
+
let responseData = '';
|
|
239
|
+
res.on('data', (chunk) => { responseData += chunk; });
|
|
240
|
+
res.on('end', () => {
|
|
241
|
+
if (res.statusCode === 401) {
|
|
242
|
+
// Token expired — retry once with fresh token
|
|
243
|
+
clearTokenCache();
|
|
244
|
+
const retryToken = getAccessToken();
|
|
245
|
+
options.headers['Authorization'] = \`Bearer \${retryToken}\`;
|
|
246
|
+
const retryData = JSON.stringify(body);
|
|
247
|
+
options.headers['Content-Length'] = Buffer.byteLength(retryData);
|
|
248
|
+
const retryReq = https.request(options, (retryRes) => {
|
|
249
|
+
let retryResponse = '';
|
|
250
|
+
retryRes.on('data', (chunk) => { retryResponse += chunk; });
|
|
251
|
+
retryRes.on('end', () => resolve(retryResponse));
|
|
252
|
+
});
|
|
253
|
+
retryReq.on('error', reject);
|
|
254
|
+
retryReq.write(retryData);
|
|
255
|
+
retryReq.end();
|
|
256
|
+
} else {
|
|
257
|
+
resolve(responseData);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
req.on('error', reject);
|
|
263
|
+
req.write(data);
|
|
264
|
+
req.end();
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Buffer stdin and split on newlines (JSON-RPC messages)
|
|
269
|
+
let buffer = '';
|
|
270
|
+
process.stdin.setEncoding('utf-8');
|
|
271
|
+
process.stdin.on('data', async (chunk) => {
|
|
272
|
+
buffer += chunk;
|
|
273
|
+
const lines = buffer.split('\\n');
|
|
274
|
+
buffer = lines.pop(); // keep incomplete line in buffer
|
|
275
|
+
|
|
276
|
+
for (const line of lines) {
|
|
277
|
+
if (!line.trim()) continue;
|
|
278
|
+
try {
|
|
279
|
+
const request = JSON.parse(line);
|
|
280
|
+
const response = await sendRequest(request);
|
|
281
|
+
process.stdout.write(response + '\\n');
|
|
282
|
+
} catch (err) {
|
|
283
|
+
const errorResponse = JSON.stringify({
|
|
284
|
+
jsonrpc: '2.0',
|
|
285
|
+
id: null,
|
|
286
|
+
error: { code: -32603, message: err.message || 'Proxy error' }
|
|
287
|
+
});
|
|
288
|
+
process.stdout.write(errorResponse + '\\n');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
process.stderr.write(\`[stitch-proxy] Ready — GCP project: \${GCP_PROJECT}\\n\`);
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Generate and write the Stitch OAuth proxy script to the target project.
|
|
299
|
+
* @param {string} targetPath - Project root directory
|
|
300
|
+
* @param {string} gcpProject - GCP project ID
|
|
301
|
+
* @returns {{ proxyPath: string }} Path to the generated proxy script
|
|
302
|
+
*/
|
|
303
|
+
export function generateStitchProxy(targetPath, gcpProject) {
|
|
304
|
+
const claudeDir = join(targetPath, '.claude');
|
|
305
|
+
if (!existsSync(claudeDir)) {
|
|
306
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const proxyPath = join(claudeDir, 'stitch-proxy.cjs');
|
|
310
|
+
writeFileSync(proxyPath, getStitchProxyTemplate(gcpProject), 'utf-8');
|
|
311
|
+
|
|
312
|
+
return { proxyPath };
|
|
313
|
+
}
|
|
314
|
+
|
|
176
315
|
/**
|
|
177
316
|
* Install a single MCP with provided credentials
|
|
178
317
|
* @param {string} targetPath - Project root directory
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MORPH-SPEC Score Aggregator
|
|
3
|
+
*
|
|
4
|
+
* Converts pass/fail validator results into weighted scores using rubric definitions.
|
|
5
|
+
* Produces prioritized feedback for the retry loop.
|
|
6
|
+
*
|
|
7
|
+
* @module score-aggregator
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from 'fs';
|
|
11
|
+
import { join, dirname } from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
/** Fallback rubric when none is configured or found. */
|
|
17
|
+
const FALLBACK_RUBRIC = {
|
|
18
|
+
id: 'fallback',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
categories: {
|
|
21
|
+
general: {
|
|
22
|
+
weight: 1.0,
|
|
23
|
+
validators: [],
|
|
24
|
+
description: 'All validators (fallback — no rubric configured)'
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
thresholds: { approve: 8.0, retry: 6.0 }
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load a rubric by ID from the project or framework directory.
|
|
32
|
+
*
|
|
33
|
+
* Search order:
|
|
34
|
+
* 1. .morph/framework/rubrics/{rubricId}.json (installed in project)
|
|
35
|
+
* 2. npm package path (node_modules/@polymorphism-tech/morph-spec/framework/rubrics/)
|
|
36
|
+
* 3. Local dev path (framework/rubrics/ relative to this module)
|
|
37
|
+
*
|
|
38
|
+
* @param {string} projectPath - Project root
|
|
39
|
+
* @param {string} rubricId - Rubric identifier (e.g. 'default', 'frontend', 'backend')
|
|
40
|
+
* @returns {Object} Parsed rubric object
|
|
41
|
+
*/
|
|
42
|
+
export function loadRubric(projectPath, rubricId = 'default') {
|
|
43
|
+
const filename = `${rubricId}.json`;
|
|
44
|
+
|
|
45
|
+
const candidates = [
|
|
46
|
+
join(projectPath, '.morph', 'framework', 'rubrics', filename),
|
|
47
|
+
join(projectPath, 'node_modules', '@polymorphism-tech', 'morph-spec', 'framework', 'rubrics', filename),
|
|
48
|
+
join(__dirname, '..', '..', '..', 'framework', 'rubrics', filename)
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
for (const candidate of candidates) {
|
|
52
|
+
if (existsSync(candidate)) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(readFileSync(candidate, 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
// Corrupted file — try next candidate
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return FALLBACK_RUBRIC;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Score a single validator result.
|
|
66
|
+
*
|
|
67
|
+
* Scoring formula:
|
|
68
|
+
* - Base score: 10
|
|
69
|
+
* - Each error: -2 (min 0)
|
|
70
|
+
* - Each warning: -1 (min 0)
|
|
71
|
+
*
|
|
72
|
+
* @param {Object} validatorResult - { passed: boolean, issues?: Array, error?: string }
|
|
73
|
+
* @returns {number} Score 0–10
|
|
74
|
+
*/
|
|
75
|
+
export function scoreValidator(validatorResult) {
|
|
76
|
+
if (!validatorResult) return 10; // Validator didn't run — assume clean
|
|
77
|
+
|
|
78
|
+
// Validator threw an error — can't assess
|
|
79
|
+
if (validatorResult.error) return 5;
|
|
80
|
+
|
|
81
|
+
const errors = (validatorResult.issues || []).filter(i => i.level === 'error').length;
|
|
82
|
+
const warnings = (validatorResult.issues || []).filter(i => i.level === 'warning').length;
|
|
83
|
+
|
|
84
|
+
return Math.max(0, 10 - (errors * 2) - (warnings * 1));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a reverse map: validatorId → category name.
|
|
89
|
+
* Validators not mapped to any category go into '_unmapped'.
|
|
90
|
+
*
|
|
91
|
+
* @param {Object} rubric
|
|
92
|
+
* @returns {Map<string, string>}
|
|
93
|
+
*/
|
|
94
|
+
function buildValidatorCategoryMap(rubric) {
|
|
95
|
+
const map = new Map();
|
|
96
|
+
for (const [categoryName, category] of Object.entries(rubric.categories)) {
|
|
97
|
+
for (const validatorId of category.validators || []) {
|
|
98
|
+
map.set(validatorId, categoryName);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return map;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Aggregate validator results into weighted scores using a rubric.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object} validationResult - From validation-runner.js: { passed, errors, warnings, issues, results }
|
|
108
|
+
* @param {Object} rubric - Loaded rubric object
|
|
109
|
+
* @param {Object} [configOverrides] - Optional threshold overrides from config.json
|
|
110
|
+
* @param {number} [configOverrides.approve] - Override approve threshold
|
|
111
|
+
* @param {number} [configOverrides.retry] - Override retry threshold
|
|
112
|
+
* @returns {{
|
|
113
|
+
* score: number,
|
|
114
|
+
* action: 'approve' | 'retry' | 'escalate',
|
|
115
|
+
* breakdown: Object,
|
|
116
|
+
* priorityFixes: Array<{ category: string, score: number, weight: number, deficit: number, issues: Array }>
|
|
117
|
+
* }}
|
|
118
|
+
*/
|
|
119
|
+
export function aggregateScores(validationResult, rubric, configOverrides = {}) {
|
|
120
|
+
const results = validationResult.results || {};
|
|
121
|
+
const validatorCategoryMap = buildValidatorCategoryMap(rubric);
|
|
122
|
+
|
|
123
|
+
// Collect scores per category
|
|
124
|
+
const breakdown = {};
|
|
125
|
+
const unmappedScores = [];
|
|
126
|
+
|
|
127
|
+
for (const [categoryName, category] of Object.entries(rubric.categories)) {
|
|
128
|
+
const mappedValidators = (category.validators || []).filter(v => results[v] !== undefined);
|
|
129
|
+
const scores = mappedValidators.map(v => scoreValidator(results[v]));
|
|
130
|
+
|
|
131
|
+
breakdown[categoryName] = {
|
|
132
|
+
score: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 10,
|
|
133
|
+
weight: category.weight,
|
|
134
|
+
validators: mappedValidators,
|
|
135
|
+
issues: mappedValidators.flatMap(v => (results[v]?.issues || []))
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Handle validators not mapped to any rubric category
|
|
140
|
+
for (const [validatorId, result] of Object.entries(results)) {
|
|
141
|
+
if (!validatorCategoryMap.has(validatorId)) {
|
|
142
|
+
unmappedScores.push(scoreValidator(result));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Handle unmapped validators
|
|
147
|
+
if (unmappedScores.length > 0) {
|
|
148
|
+
const unmappedIds = Object.keys(results).filter(id => !validatorCategoryMap.has(id));
|
|
149
|
+
if (Object.keys(breakdown).length === 0) {
|
|
150
|
+
// No categories at all — put everything in 'general'
|
|
151
|
+
breakdown.general = {
|
|
152
|
+
score: unmappedScores.reduce((a, b) => a + b, 0) / unmappedScores.length,
|
|
153
|
+
weight: 1.0,
|
|
154
|
+
validators: unmappedIds,
|
|
155
|
+
issues: unmappedIds.flatMap(id => results[id]?.issues || [])
|
|
156
|
+
};
|
|
157
|
+
} else {
|
|
158
|
+
// Categories exist but some validators aren't mapped — add as low-weight 'other'
|
|
159
|
+
const unmappedAvg = unmappedScores.reduce((a, b) => a + b, 0) / unmappedScores.length;
|
|
160
|
+
breakdown._unmapped = {
|
|
161
|
+
score: unmappedAvg,
|
|
162
|
+
weight: 0.05,
|
|
163
|
+
validators: unmappedIds,
|
|
164
|
+
issues: unmappedIds.flatMap(id => results[id]?.issues || [])
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Calculate weighted final score
|
|
170
|
+
const totalWeight = Object.values(breakdown).reduce((sum, cat) => sum + cat.weight, 0);
|
|
171
|
+
const weightedScore = totalWeight > 0
|
|
172
|
+
? Object.values(breakdown).reduce((sum, cat) => sum + (cat.score * cat.weight), 0) / totalWeight
|
|
173
|
+
: 10;
|
|
174
|
+
|
|
175
|
+
// Round to 1 decimal
|
|
176
|
+
const score = Math.round(weightedScore * 10) / 10;
|
|
177
|
+
|
|
178
|
+
// Determine action
|
|
179
|
+
const approveThreshold = configOverrides.approve ?? rubric.thresholds?.approve ?? 8.0;
|
|
180
|
+
const retryThreshold = configOverrides.retry ?? rubric.thresholds?.retry ?? 6.0;
|
|
181
|
+
|
|
182
|
+
let action;
|
|
183
|
+
if (score >= approveThreshold) {
|
|
184
|
+
action = 'approve';
|
|
185
|
+
} else if (score >= retryThreshold) {
|
|
186
|
+
action = 'retry';
|
|
187
|
+
} else {
|
|
188
|
+
action = 'escalate';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Build priority fixes — sorted by impact (weight * deficit from 10)
|
|
192
|
+
const priorityFixes = Object.entries(breakdown)
|
|
193
|
+
.filter(([, cat]) => cat.issues.length > 0)
|
|
194
|
+
.map(([categoryName, cat]) => ({
|
|
195
|
+
category: categoryName,
|
|
196
|
+
score: Math.round(cat.score * 10) / 10,
|
|
197
|
+
weight: cat.weight,
|
|
198
|
+
deficit: Math.round((10 - cat.score) * cat.weight * 10) / 10,
|
|
199
|
+
issues: cat.issues
|
|
200
|
+
}))
|
|
201
|
+
.sort((a, b) => b.deficit - a.deficit);
|
|
202
|
+
|
|
203
|
+
return { score, action, breakdown, priorityFixes, approveThreshold };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Format score breakdown as a human-readable string for hook injection.
|
|
208
|
+
*
|
|
209
|
+
* @param {Object} scoreResult - From aggregateScores()
|
|
210
|
+
* @param {number} [approveThreshold] - For display
|
|
211
|
+
* @returns {string}
|
|
212
|
+
*/
|
|
213
|
+
export function formatScoreBreakdown(scoreResult, approveThreshold = 8.0) {
|
|
214
|
+
const lines = [];
|
|
215
|
+
|
|
216
|
+
lines.push(`Score: ${scoreResult.score}/10 (threshold: ${approveThreshold})`);
|
|
217
|
+
lines.push('');
|
|
218
|
+
|
|
219
|
+
if (scoreResult.priorityFixes.length > 0) {
|
|
220
|
+
lines.push('Priority fixes (by impact):');
|
|
221
|
+
for (let i = 0; i < scoreResult.priorityFixes.length; i++) {
|
|
222
|
+
const fix = scoreResult.priorityFixes[i];
|
|
223
|
+
const topIssue = fix.issues[0];
|
|
224
|
+
const issueText = topIssue
|
|
225
|
+
? `${topIssue.message}${topIssue.file ? ` (${topIssue.file}${topIssue.line ? ':' + topIssue.line : ''})` : ''}`
|
|
226
|
+
: 'Issues detected';
|
|
227
|
+
lines.push(`${i + 1}. [${fix.category}: ${fix.score}/10] ${issueText}`);
|
|
228
|
+
// Show additional issues count if more than 1
|
|
229
|
+
if (fix.issues.length > 1) {
|
|
230
|
+
lines.push(` ... and ${fix.issues.length - 1} more issue(s) in this category`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
lines.push('');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
lines.push('Score breakdown:');
|
|
237
|
+
for (const [category, data] of Object.entries(scoreResult.breakdown)) {
|
|
238
|
+
const pct = Math.round(data.weight * 100);
|
|
239
|
+
const indicator = data.score >= approveThreshold ? '\u2713' : data.score >= 6 ? '\u2193 needs work' : '\u2193\u2193 critical';
|
|
240
|
+
lines.push(` ${category}: ${Math.round(data.score * 10) / 10}/10 (weight ${pct}%) ${indicator}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return lines.join('\n');
|
|
244
|
+
}
|
|
@@ -13,6 +13,7 @@ import chalk from 'chalk';
|
|
|
13
13
|
import { loadState } from '../../core/state/state-manager.js';
|
|
14
14
|
import { getAbsoluteOutputPath, resolveContractsOutputType } from '../../core/paths/output-schema.js';
|
|
15
15
|
import { getStackProfile } from '../stack/stack-profile.js';
|
|
16
|
+
import { aggregateScores, loadRubric } from './score-aggregator.js';
|
|
16
17
|
|
|
17
18
|
/** Validators that only make sense for .NET projects. */
|
|
18
19
|
const DOTNET_ONLY_VALIDATORS = new Set([
|
|
@@ -172,6 +173,17 @@ export async function runValidation(projectPath, featureName, options = {}) {
|
|
|
172
173
|
? `✅ All validations passed (${result.warnings} warnings)`
|
|
173
174
|
: `❌ ${result.errors} error(s), ${result.warnings} warning(s)`;
|
|
174
175
|
|
|
176
|
+
// Score aggregation — layered on top of pass/fail
|
|
177
|
+
try {
|
|
178
|
+
const rubricId = readRubricConfig(projectPath);
|
|
179
|
+
const rubric = loadRubric(projectPath, rubricId);
|
|
180
|
+
const thresholdOverrides = readThresholdConfig(projectPath);
|
|
181
|
+
result.scores = aggregateScores(result, rubric, thresholdOverrides);
|
|
182
|
+
} catch {
|
|
183
|
+
// Score aggregation is non-blocking — if it fails, pass/fail still works
|
|
184
|
+
result.scores = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
175
187
|
return result;
|
|
176
188
|
}
|
|
177
189
|
|
|
@@ -303,3 +315,35 @@ export function formatValidationResults(result) {
|
|
|
303
315
|
|
|
304
316
|
console.log('');
|
|
305
317
|
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Read rubric ID from project config.
|
|
321
|
+
* @param {string} projectPath
|
|
322
|
+
* @returns {string}
|
|
323
|
+
*/
|
|
324
|
+
function readRubricConfig(projectPath) {
|
|
325
|
+
try {
|
|
326
|
+
const configPath = join(projectPath, '.morph', 'config', 'config.json');
|
|
327
|
+
if (!existsSync(configPath)) return 'default';
|
|
328
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
329
|
+
return config.validation?.rubric ?? 'default';
|
|
330
|
+
} catch {
|
|
331
|
+
return 'default';
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Read threshold overrides from project config.
|
|
337
|
+
* @param {string} projectPath
|
|
338
|
+
* @returns {Object} { approve?: number, retry?: number }
|
|
339
|
+
*/
|
|
340
|
+
function readThresholdConfig(projectPath) {
|
|
341
|
+
try {
|
|
342
|
+
const configPath = join(projectPath, '.morph', 'config', 'config.json');
|
|
343
|
+
if (!existsSync(configPath)) return {};
|
|
344
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
345
|
+
return config.validation?.thresholds ?? {};
|
|
346
|
+
} catch {
|
|
347
|
+
return {};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -227,6 +227,14 @@ export async function setupInfra(targetPath, { _exec = execSync, reinstall = fal
|
|
|
227
227
|
await copyDirectory(hooksSrc, hooksDest);
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// --- 7b. Copy framework/rubrics → .morph/framework/rubrics ---
|
|
231
|
+
log('Step 7b: Copying rubrics...');
|
|
232
|
+
const rubricsSrc = join(FRAMEWORK_DIR, 'rubrics');
|
|
233
|
+
const rubricsDest = join(frameworkDestDir, 'rubrics');
|
|
234
|
+
if (await pathExists(rubricsSrc)) {
|
|
235
|
+
await copyDirectory(rubricsSrc, rubricsDest);
|
|
236
|
+
}
|
|
237
|
+
|
|
230
238
|
// --- 8. Copy framework/agents.json → .morph/framework/agents.json ---
|
|
231
239
|
log('Step 8: Copying agents.json...');
|
|
232
240
|
const agentsSrc = join(FRAMEWORK_DIR, 'agents.json');
|