@polymorphism-tech/morph-spec 4.18.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 +47 -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 +98 -0
- package/framework/agents/architecture/morph-spec-architect.md +66 -0
- package/framework/agents/architecture/packages-validator.md +89 -0
- package/framework/agents/architecture/security-expert.md +79 -0
- package/framework/agents/backend/ms-agent-expert.md +1 -1
- package/framework/agents/frontend/design-system-validator.md +100 -0
- package/framework/agents/frontend/nextjs-component-validator.md +97 -0
- package/framework/agents/frontend/{ui-ux-designer.md → ui-designer.md} +31 -0
- package/framework/agents.json +1 -1
- package/framework/hooks/claude-code/post-tool-use/dispatch.js +56 -0
- package/framework/hooks/claude-code/post-tool-use/handle-tool-failure.js +78 -44
- package/framework/hooks/claude-code/post-tool-use/loop-detect.js +103 -0
- package/framework/hooks/claude-code/post-tool-use/validator-feedback.js +60 -11
- package/framework/hooks/claude-code/session-start/inject-morph-context.js +21 -2
- package/framework/hooks/claude-code/stop/validate-completion.js +48 -1
- package/framework/hooks/shared/activity-logger.js +56 -1
- package/framework/hooks/shared/build-check.js +79 -0
- package/framework/hooks/shared/payload-utils.js +39 -0
- package/framework/hooks/shared/session-snapshot.js +105 -0
- package/framework/phases.json +1 -1
- 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 +34 -1
- 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 +11 -1
- package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +12 -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 +119 -11
- package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
- package/framework/standards/ai-agents/production.md +5 -2
- package/framework/standards/ai-agents/setup.md +13 -14
- package/framework/standards/ai-agents/workflows.md +8 -6
- package/framework/standards/backend/database/vector-search-rag.md +1 -1
- package/framework/templates/code/dotnet/jobs/agent.cs +0 -1
- package/package.json +1 -1
- package/src/commands/dashboard/dashboard.js +53 -0
- package/src/commands/mcp/mcp-setup.js +21 -0
- package/src/commands/project/init.js +11 -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/contracts/sprint-contract-generator.js +137 -0
- package/src/lib/generators/recap-generator.js +15 -0
- package/src/lib/installers/mcp-installer.js +25 -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
- package/src/utils/hooks-installer.js +12 -1
|
@@ -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;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprint Contract Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates sprint contracts from tasks.md + spec.md for the evaluator.
|
|
5
|
+
* Contracts define objective "definition of done" criteria.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { getBuildCommand } from '../../../framework/hooks/shared/build-check.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse a task entry from tasks.md markdown.
|
|
14
|
+
*
|
|
15
|
+
* Expected format: ## T001: Task Title [FR001, FR003]
|
|
16
|
+
*
|
|
17
|
+
* @param {string} content - tasks.md content
|
|
18
|
+
* @param {string} taskId - Task ID (e.g., 'T001')
|
|
19
|
+
* @returns {{ id: string, title: string, frs: string[], description: string }|null}
|
|
20
|
+
*/
|
|
21
|
+
export function parseTaskFromMarkdown(content, taskId) {
|
|
22
|
+
const pattern = new RegExp(
|
|
23
|
+
`##\\s+${taskId}:\\s+(.+?)(?:\\s+\\[([^\\]]+)\\])?\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`,
|
|
24
|
+
'i'
|
|
25
|
+
);
|
|
26
|
+
const match = content.match(pattern);
|
|
27
|
+
if (!match) return null;
|
|
28
|
+
|
|
29
|
+
const title = match[1].trim();
|
|
30
|
+
const frString = match[2] || '';
|
|
31
|
+
const description = match[3].trim();
|
|
32
|
+
|
|
33
|
+
const frs = frString
|
|
34
|
+
? frString.split(',').map(fr => fr.trim()).filter(Boolean)
|
|
35
|
+
: [];
|
|
36
|
+
|
|
37
|
+
return { id: taskId, title, frs, description };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate a sprint contract for a task.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} projectPath - Project root
|
|
44
|
+
* @param {string} featureName - Feature name
|
|
45
|
+
* @param {string} taskId - Task ID
|
|
46
|
+
* @returns {Object|null} Contract object or null if unable to generate
|
|
47
|
+
*/
|
|
48
|
+
export function generateSprintContract(projectPath, featureName, taskId) {
|
|
49
|
+
const featureDir = join(projectPath, '.morph/features', featureName);
|
|
50
|
+
const tasksPath = join(featureDir, '4-tasks/tasks.md');
|
|
51
|
+
|
|
52
|
+
if (!existsSync(tasksPath)) return null;
|
|
53
|
+
|
|
54
|
+
const tasksContent = readFileSync(tasksPath, 'utf-8');
|
|
55
|
+
const task = parseTaskFromMarkdown(tasksContent, taskId);
|
|
56
|
+
if (!task) return null;
|
|
57
|
+
|
|
58
|
+
// Read spec for FR-based criteria
|
|
59
|
+
const specPath = join(featureDir, '2-design/spec.md');
|
|
60
|
+
let definitionOfDone = [];
|
|
61
|
+
if (task.frs.length > 0 && existsSync(specPath)) {
|
|
62
|
+
const specContent = readFileSync(specPath, 'utf-8');
|
|
63
|
+
for (const fr of task.frs) {
|
|
64
|
+
const frPattern = new RegExp(`##\\s+${fr}[:\\s]([\\s\\S]*?)(?=\\n##\\s|$)`, 'i');
|
|
65
|
+
const frMatch = specContent.match(frPattern);
|
|
66
|
+
if (frMatch) {
|
|
67
|
+
const bullets = frMatch[1].match(/^[\s]*[-*]\s+(.+)/gm);
|
|
68
|
+
if (bullets) {
|
|
69
|
+
definitionOfDone.push(...bullets.map(b => b.replace(/^[\s]*[-*]\s+/, '').trim()));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (definitionOfDone.length === 0) {
|
|
76
|
+
definitionOfDone = [task.description || `Complete ${task.title}`];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Read stack for build commands
|
|
80
|
+
let stack = '';
|
|
81
|
+
try {
|
|
82
|
+
const configPath = join(projectPath, '.morph/config/config.json');
|
|
83
|
+
if (existsSync(configPath)) {
|
|
84
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
85
|
+
stack = config?.project?.stack || '';
|
|
86
|
+
}
|
|
87
|
+
} catch { /* ignore */ }
|
|
88
|
+
|
|
89
|
+
const hasTsConfig = existsSync(join(projectPath, 'tsconfig.json'));
|
|
90
|
+
const buildCmd = getBuildCommand(stack, hasTsConfig);
|
|
91
|
+
|
|
92
|
+
const contract = {
|
|
93
|
+
task: taskId,
|
|
94
|
+
title: task.title,
|
|
95
|
+
functionalRequirements: task.frs,
|
|
96
|
+
definitionOfDone,
|
|
97
|
+
verification: {
|
|
98
|
+
build: buildCmd || 'echo "No build command configured"',
|
|
99
|
+
test: stack.includes('dotnet') ? `dotnet test --filter ${task.title.replace(/\s+/g, '')}` : 'npm test',
|
|
100
|
+
e2e: null
|
|
101
|
+
},
|
|
102
|
+
maxAttempts: 3,
|
|
103
|
+
generatedAt: new Date().toISOString()
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Write contract to disk
|
|
107
|
+
const contractsDir = join(featureDir, '5-implement/contracts');
|
|
108
|
+
mkdirSync(contractsDir, { recursive: true });
|
|
109
|
+
const yamlContent = contractToYaml(contract);
|
|
110
|
+
writeFileSync(join(contractsDir, `${taskId}-contract.yaml`), yamlContent, 'utf-8');
|
|
111
|
+
|
|
112
|
+
return contract;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Simple YAML serializer for contracts (no dependency needed).
|
|
117
|
+
*
|
|
118
|
+
* @param {Object} contract - Contract object
|
|
119
|
+
* @returns {string} YAML string
|
|
120
|
+
*/
|
|
121
|
+
function contractToYaml(contract) {
|
|
122
|
+
const lines = [
|
|
123
|
+
`task: ${contract.task}`,
|
|
124
|
+
`title: "${contract.title}"`,
|
|
125
|
+
`functionalRequirements:`,
|
|
126
|
+
...contract.functionalRequirements.map(fr => ` - ${fr}`),
|
|
127
|
+
`definitionOfDone:`,
|
|
128
|
+
...contract.definitionOfDone.map(d => ` - "${d}"`),
|
|
129
|
+
`verification:`,
|
|
130
|
+
` build: "${contract.verification.build}"`,
|
|
131
|
+
` test: "${contract.verification.test}"`,
|
|
132
|
+
` e2e: ${contract.verification.e2e ? `"${contract.verification.e2e}"` : 'null'}`,
|
|
133
|
+
`maxAttempts: ${contract.maxAttempts}`,
|
|
134
|
+
`generatedAt: "${contract.generatedAt}"`,
|
|
135
|
+
];
|
|
136
|
+
return lines.join('\n') + '\n';
|
|
137
|
+
}
|
|
@@ -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));
|
|
@@ -148,6 +148,31 @@ export async function installAutoMcps(targetPath, mcpsToInstall) {
|
|
|
148
148
|
return installMcpServers(targetPath, servers);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Install companion skills for MCPs that define them (e.g., Stitch agent skills).
|
|
153
|
+
* Reads the `companionSkills` field from registry entries and runs the install command.
|
|
154
|
+
* @param {Object} mcpsInstalled - Map of mcpName → mcpEntry that were just installed
|
|
155
|
+
* @param {Function} exec - execSync function (for testing)
|
|
156
|
+
* @returns {Array<{name: string, repo: string, success: boolean}>}
|
|
157
|
+
*/
|
|
158
|
+
export function installCompanionSkills(mcpsInstalled, exec = execSync) {
|
|
159
|
+
const results = [];
|
|
160
|
+
|
|
161
|
+
for (const [name, entry] of Object.entries(mcpsInstalled)) {
|
|
162
|
+
const companion = entry.companionSkills;
|
|
163
|
+
if (!companion?.installCommand) continue;
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
exec(companion.installCommand, { stdio: 'inherit', timeout: 30000 });
|
|
167
|
+
results.push({ name, repo: companion.repo, success: true });
|
|
168
|
+
} catch {
|
|
169
|
+
results.push({ name, repo: companion.repo, success: false });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
|
|
151
176
|
/**
|
|
152
177
|
* Install a single MCP with provided credentials
|
|
153
178
|
* @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');
|
|
@@ -15,7 +15,7 @@ import { homedir } from 'os';
|
|
|
15
15
|
import { execSync } from 'child_process';
|
|
16
16
|
|
|
17
17
|
/** Current hooks schema version — bump when hook definitions change */
|
|
18
|
-
const HOOKS_VERSION = '
|
|
18
|
+
const HOOKS_VERSION = '3.0.0';
|
|
19
19
|
|
|
20
20
|
/** Marker for old dispatch.js (v1) */
|
|
21
21
|
const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
|
|
@@ -155,6 +155,17 @@ Otherwise respond: {"ok": true}`
|
|
|
155
155
|
]
|
|
156
156
|
},
|
|
157
157
|
|
|
158
|
+
// === PostToolUse: All — loop detection ===
|
|
159
|
+
{
|
|
160
|
+
event: 'PostToolUse',
|
|
161
|
+
matcher: null,
|
|
162
|
+
hooks: [{
|
|
163
|
+
type: 'command',
|
|
164
|
+
command: 'node framework/hooks/claude-code/post-tool-use/loop-detect.js',
|
|
165
|
+
timeout: 5000
|
|
166
|
+
}]
|
|
167
|
+
},
|
|
168
|
+
|
|
158
169
|
// === Stop ===
|
|
159
170
|
{
|
|
160
171
|
event: 'Stop',
|