@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.
Files changed (86) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +11 -0
  3. package/bin/task-manager.js +47 -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 +98 -0
  8. package/framework/agents/architecture/morph-spec-architect.md +66 -0
  9. package/framework/agents/architecture/packages-validator.md +89 -0
  10. package/framework/agents/architecture/security-expert.md +79 -0
  11. package/framework/agents/backend/ms-agent-expert.md +1 -1
  12. package/framework/agents/frontend/design-system-validator.md +100 -0
  13. package/framework/agents/frontend/nextjs-component-validator.md +97 -0
  14. package/framework/agents/frontend/{ui-ux-designer.md → ui-designer.md} +31 -0
  15. package/framework/agents.json +1 -1
  16. package/framework/hooks/claude-code/post-tool-use/dispatch.js +56 -0
  17. package/framework/hooks/claude-code/post-tool-use/handle-tool-failure.js +78 -44
  18. package/framework/hooks/claude-code/post-tool-use/loop-detect.js +103 -0
  19. package/framework/hooks/claude-code/post-tool-use/validator-feedback.js +60 -11
  20. package/framework/hooks/claude-code/session-start/inject-morph-context.js +21 -2
  21. package/framework/hooks/claude-code/stop/validate-completion.js +48 -1
  22. package/framework/hooks/shared/activity-logger.js +56 -1
  23. package/framework/hooks/shared/build-check.js +79 -0
  24. package/framework/hooks/shared/payload-utils.js +39 -0
  25. package/framework/hooks/shared/session-snapshot.js +105 -0
  26. package/framework/phases.json +1 -1
  27. package/framework/rubrics/backend.json +36 -0
  28. package/framework/rubrics/default.json +36 -0
  29. package/framework/rubrics/frontend.json +36 -0
  30. package/framework/skills/level-0-meta/mcp-registry.json +34 -1
  31. package/framework/skills/level-0-meta/morph-frontend-review/SKILL.md +1 -1
  32. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
  33. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +11 -1
  34. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +12 -1
  35. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +1 -1
  36. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +1 -1
  37. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +119 -11
  38. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
  39. package/framework/standards/ai-agents/production.md +5 -2
  40. package/framework/standards/ai-agents/setup.md +13 -14
  41. package/framework/standards/ai-agents/workflows.md +8 -6
  42. package/framework/standards/backend/database/vector-search-rag.md +1 -1
  43. package/framework/templates/code/dotnet/jobs/agent.cs +0 -1
  44. package/package.json +1 -1
  45. package/src/commands/dashboard/dashboard.js +53 -0
  46. package/src/commands/mcp/mcp-setup.js +21 -0
  47. package/src/commands/project/init.js +11 -0
  48. package/src/dashboard/app/App.jsx +31 -0
  49. package/src/dashboard/app/components/DetailPanel.jsx +11 -0
  50. package/src/dashboard/app/components/FlowGraph.jsx +17 -0
  51. package/src/dashboard/app/components/SearchFilter.jsx +22 -0
  52. package/src/dashboard/app/components/Sidebar.jsx +45 -0
  53. package/src/dashboard/app/hooks/useDetailPanel.js +15 -0
  54. package/src/dashboard/app/main.jsx +13 -0
  55. package/src/dashboard/app/pages/AgentMap.jsx +371 -0
  56. package/src/dashboard/app/pages/HookInspector.jsx +418 -0
  57. package/src/dashboard/app/pages/LiveState.jsx +239 -0
  58. package/src/dashboard/app/pages/PhasePipeline.jsx +410 -0
  59. package/src/dashboard/app/pages/RulesMap.jsx +273 -0
  60. package/src/dashboard/app/pages/SkillsLibrary.jsx +367 -0
  61. package/src/dashboard/app/pages/Standards.jsx +332 -0
  62. package/src/dashboard/app/pages/Workflows.jsx +346 -0
  63. package/src/dashboard/app/styles/index.css +47 -0
  64. package/src/dashboard/index.html +12 -0
  65. package/src/dashboard/package.json +24 -0
  66. package/src/dashboard/scanner/agents-scanner.js +35 -0
  67. package/src/dashboard/scanner/hooks-scanner.js +41 -0
  68. package/src/dashboard/scanner/index.js +21 -0
  69. package/src/dashboard/scanner/phases-scanner.js +15 -0
  70. package/src/dashboard/scanner/rubrics-scanner.js +12 -0
  71. package/src/dashboard/scanner/rules-scanner.js +37 -0
  72. package/src/dashboard/scanner/skills-scanner.js +43 -0
  73. package/src/dashboard/scanner/squads-scanner.js +13 -0
  74. package/src/dashboard/scanner/standards-scanner.js +26 -0
  75. package/src/dashboard/scanner/state-scanner.js +8 -0
  76. package/src/dashboard/scanner/workflows-scanner.js +18 -0
  77. package/src/dashboard/vite-plugin-morph-data.js +17 -0
  78. package/src/dashboard/vite.config.js +24 -0
  79. package/src/lib/checkpoints/checkpoint-hooks.js +2 -1
  80. package/src/lib/contracts/sprint-contract-generator.js +137 -0
  81. package/src/lib/generators/recap-generator.js +15 -0
  82. package/src/lib/installers/mcp-installer.js +25 -0
  83. package/src/lib/validators/score-aggregator.js +244 -0
  84. package/src/lib/validators/validation-runner.js +44 -0
  85. package/src/scripts/setup-infra.js +8 -0
  86. 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 = '2.14.0';
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',