@polymorphism-tech/morph-spec 4.19.0 → 4.20.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/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/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/generators/recap-generator.js +15 -0
- 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;
|
|
@@ -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));
|
|
@@ -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');
|