@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.
Files changed (66) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +11 -0
  3. package/bin/task-manager.js +35 -10
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/agents/architecture/architecture-expert.md +12 -0
  8. package/framework/agents/architecture/packages-validator.md +12 -0
  9. package/framework/agents/architecture/security-expert.md +12 -0
  10. package/framework/agents/frontend/design-system-validator.md +12 -0
  11. package/framework/agents/frontend/nextjs-component-validator.md +12 -0
  12. package/framework/hooks/claude-code/post-tool-use/dispatch.js +20 -3
  13. package/framework/hooks/claude-code/post-tool-use/validator-feedback.js +60 -11
  14. package/framework/rubrics/backend.json +36 -0
  15. package/framework/rubrics/default.json +36 -0
  16. package/framework/rubrics/frontend.json +36 -0
  17. package/framework/skills/level-0-meta/mcp-registry.json +21 -8
  18. package/framework/skills/level-0-meta/morph-frontend-review/SKILL.md +1 -1
  19. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
  20. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +1 -1
  21. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +1 -1
  22. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +1 -1
  23. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +1 -1
  24. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +1 -1
  25. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
  26. package/package.json +1 -1
  27. package/src/commands/dashboard/dashboard.js +53 -0
  28. package/src/commands/mcp/mcp-setup.js +99 -2
  29. package/src/dashboard/app/App.jsx +31 -0
  30. package/src/dashboard/app/components/DetailPanel.jsx +11 -0
  31. package/src/dashboard/app/components/FlowGraph.jsx +17 -0
  32. package/src/dashboard/app/components/SearchFilter.jsx +22 -0
  33. package/src/dashboard/app/components/Sidebar.jsx +45 -0
  34. package/src/dashboard/app/hooks/useDetailPanel.js +15 -0
  35. package/src/dashboard/app/main.jsx +13 -0
  36. package/src/dashboard/app/pages/AgentMap.jsx +371 -0
  37. package/src/dashboard/app/pages/HookInspector.jsx +418 -0
  38. package/src/dashboard/app/pages/LiveState.jsx +239 -0
  39. package/src/dashboard/app/pages/PhasePipeline.jsx +410 -0
  40. package/src/dashboard/app/pages/RulesMap.jsx +273 -0
  41. package/src/dashboard/app/pages/SkillsLibrary.jsx +367 -0
  42. package/src/dashboard/app/pages/Standards.jsx +332 -0
  43. package/src/dashboard/app/pages/Workflows.jsx +346 -0
  44. package/src/dashboard/app/styles/index.css +47 -0
  45. package/src/dashboard/index.html +12 -0
  46. package/src/dashboard/package.json +24 -0
  47. package/src/dashboard/scanner/agents-scanner.js +35 -0
  48. package/src/dashboard/scanner/hooks-scanner.js +41 -0
  49. package/src/dashboard/scanner/index.js +21 -0
  50. package/src/dashboard/scanner/phases-scanner.js +15 -0
  51. package/src/dashboard/scanner/rubrics-scanner.js +12 -0
  52. package/src/dashboard/scanner/rules-scanner.js +37 -0
  53. package/src/dashboard/scanner/skills-scanner.js +43 -0
  54. package/src/dashboard/scanner/squads-scanner.js +13 -0
  55. package/src/dashboard/scanner/standards-scanner.js +26 -0
  56. package/src/dashboard/scanner/state-scanner.js +8 -0
  57. package/src/dashboard/scanner/workflows-scanner.js +18 -0
  58. package/src/dashboard/vite-plugin-morph-data.js +17 -0
  59. package/src/dashboard/vite.config.js +24 -0
  60. package/src/lib/checkpoints/checkpoint-hooks.js +2 -1
  61. package/src/lib/detectors/index.js +1 -1
  62. package/src/lib/generators/recap-generator.js +15 -0
  63. package/src/lib/installers/mcp-installer.js +140 -1
  64. package/src/lib/validators/score-aggregator.js +244 -0
  65. package/src/lib/validators/validation-runner.js +44 -0
  66. 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');