@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,346 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
|
2
|
+
import { Handle, Position } from '@xyflow/react';
|
|
3
|
+
import morphData from 'virtual:morph-data';
|
|
4
|
+
import FlowGraph from '../components/FlowGraph.jsx';
|
|
5
|
+
import DetailPanel from '../components/DetailPanel.jsx';
|
|
6
|
+
import { useDetailPanel } from '../hooks/useDetailPanel.js';
|
|
7
|
+
|
|
8
|
+
// ─── Hover Context ────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const HoverContext = createContext({ hoveredPhase: null, setHoveredPhase: () => {} });
|
|
11
|
+
|
|
12
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
// Canonical phase order (we render exactly these 7 columns)
|
|
15
|
+
const PHASE_ORDER = ['proposal', 'uiux', 'design', 'plan', 'tasks', 'implement', 'sync'];
|
|
16
|
+
|
|
17
|
+
const PHASE_X_GAP = 200;
|
|
18
|
+
const PHASE_W = 160;
|
|
19
|
+
const ROW_H = 130;
|
|
20
|
+
const LABEL_W = 160;
|
|
21
|
+
const LABEL_X = -LABEL_W - 20;
|
|
22
|
+
const PHASE_START_X = 0;
|
|
23
|
+
|
|
24
|
+
// ─── Node Components ──────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function WorkflowLabelNode({ data }) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className="rounded-lg px-4 py-3 cursor-pointer select-none"
|
|
30
|
+
style={{
|
|
31
|
+
width: LABEL_W,
|
|
32
|
+
background: '#0f172a',
|
|
33
|
+
border: '1.5px solid #334155',
|
|
34
|
+
boxShadow: '0 0 8px #33415533',
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<Handle type="source" position={Position.Right} style={{ background: '#475569', width: 7, height: 7 }} />
|
|
38
|
+
<div className="text-white text-xs font-bold leading-tight truncate" title={data.label}>
|
|
39
|
+
{data.label}
|
|
40
|
+
</div>
|
|
41
|
+
<div className="text-gray-400 text-[10px] mt-1 leading-snug line-clamp-2" title={data.description}>
|
|
42
|
+
{data.description}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function WorkflowPhaseNode({ data }) {
|
|
49
|
+
const { hoveredPhase, setHoveredPhase } = useContext(HoverContext);
|
|
50
|
+
const isSkipped = data.skipped;
|
|
51
|
+
const phaseName = data.phase;
|
|
52
|
+
|
|
53
|
+
const isHighlighted = hoveredPhase !== null && hoveredPhase === phaseName;
|
|
54
|
+
const isDimmed = hoveredPhase !== null && hoveredPhase !== phaseName;
|
|
55
|
+
|
|
56
|
+
const bg = isSkipped ? '#111827' : '#1e3a5f';
|
|
57
|
+
const border = isSkipped ? '#374151' : isHighlighted ? '#60a5fa' : '#3b82f6';
|
|
58
|
+
const textColor = isSkipped ? '#6b7280' : '#93c5fd';
|
|
59
|
+
|
|
60
|
+
let opacity = isSkipped ? 0.5 : 1;
|
|
61
|
+
if (isDimmed) opacity = isSkipped ? 0.25 : 0.35;
|
|
62
|
+
|
|
63
|
+
const boxShadow = isHighlighted
|
|
64
|
+
? '0 0 0 2px #60a5fa, 0 0 12px #3b82f6aa'
|
|
65
|
+
: isSkipped
|
|
66
|
+
? 'none'
|
|
67
|
+
: '0 0 6px #3b82f622';
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
className="rounded-lg px-3 py-2 select-none cursor-default"
|
|
72
|
+
style={{
|
|
73
|
+
width: PHASE_W - 20,
|
|
74
|
+
background: isHighlighted ? '#1e4a7f' : bg,
|
|
75
|
+
border: `1.5px ${isSkipped ? 'dashed' : 'solid'} ${border}`,
|
|
76
|
+
boxShadow,
|
|
77
|
+
opacity,
|
|
78
|
+
transition: 'opacity 0.15s ease, box-shadow 0.15s ease, background 0.15s ease, border-color 0.15s ease',
|
|
79
|
+
}}
|
|
80
|
+
onMouseEnter={() => setHoveredPhase(phaseName)}
|
|
81
|
+
onMouseLeave={() => setHoveredPhase(null)}
|
|
82
|
+
>
|
|
83
|
+
<Handle type="target" position={Position.Left} style={{ background: border, width: 6, height: 6 }} />
|
|
84
|
+
<Handle type="source" position={Position.Right} style={{ background: border, width: 6, height: 6 }} />
|
|
85
|
+
<div
|
|
86
|
+
className="text-xs font-semibold leading-tight"
|
|
87
|
+
style={{
|
|
88
|
+
color: isSkipped ? '#6b7280' : isHighlighted ? '#bfdbfe' : '#ffffff',
|
|
89
|
+
textDecoration: isSkipped ? 'line-through' : 'none',
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{data.label}
|
|
93
|
+
</div>
|
|
94
|
+
{isSkipped && (
|
|
95
|
+
<div className="text-[9px] mt-0.5" style={{ color: '#6b7280' }}>skip</div>
|
|
96
|
+
)}
|
|
97
|
+
{!isSkipped && (
|
|
98
|
+
<div className="text-[9px] mt-0.5" style={{ color: textColor }}>run</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const nodeTypes = { workflowLabelNode: WorkflowLabelNode, workflowPhaseNode: WorkflowPhaseNode };
|
|
105
|
+
|
|
106
|
+
// ─── Graph Builder ────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function buildGraph(workflows) {
|
|
109
|
+
const nodes = [];
|
|
110
|
+
const edges = [];
|
|
111
|
+
|
|
112
|
+
// Sort workflows by priority (ascending: lower priority = simpler = shown first)
|
|
113
|
+
const sorted = [...workflows].sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
|
114
|
+
|
|
115
|
+
sorted.forEach((wf, rowIdx) => {
|
|
116
|
+
const rowY = rowIdx * ROW_H;
|
|
117
|
+
const skipSet = new Set(wf.phases.skip || []);
|
|
118
|
+
|
|
119
|
+
// Label node on the left
|
|
120
|
+
nodes.push({
|
|
121
|
+
id: `wf-label-${wf.id}`,
|
|
122
|
+
type: 'workflowLabelNode',
|
|
123
|
+
position: { x: LABEL_X, y: rowY + 10 },
|
|
124
|
+
data: {
|
|
125
|
+
label: wf.name,
|
|
126
|
+
description: wf.description,
|
|
127
|
+
wfRef: wf,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Phase nodes
|
|
132
|
+
PHASE_ORDER.forEach((phase, colIdx) => {
|
|
133
|
+
const isSkipped = skipSet.has(phase);
|
|
134
|
+
const phaseNodeId = `wf-${wf.id}-phase-${phase}`;
|
|
135
|
+
|
|
136
|
+
nodes.push({
|
|
137
|
+
id: phaseNodeId,
|
|
138
|
+
type: 'workflowPhaseNode',
|
|
139
|
+
position: { x: PHASE_START_X + colIdx * PHASE_X_GAP, y: rowY },
|
|
140
|
+
data: {
|
|
141
|
+
label: phase,
|
|
142
|
+
skipped: isSkipped,
|
|
143
|
+
active: !isSkipped,
|
|
144
|
+
wfId: wf.id,
|
|
145
|
+
phase,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Edge to next phase in this row
|
|
150
|
+
const nextPhase = PHASE_ORDER[colIdx + 1];
|
|
151
|
+
if (nextPhase) {
|
|
152
|
+
const nextNodeId = `wf-${wf.id}-phase-${nextPhase}`;
|
|
153
|
+
const isNextSkipped = skipSet.has(nextPhase);
|
|
154
|
+
edges.push({
|
|
155
|
+
id: `edge-${wf.id}-${phase}-${nextPhase}`,
|
|
156
|
+
source: phaseNodeId,
|
|
157
|
+
target: nextNodeId,
|
|
158
|
+
type: 'smoothstep',
|
|
159
|
+
style: {
|
|
160
|
+
stroke: (isSkipped || isNextSkipped) ? '#374151' : '#3b82f6',
|
|
161
|
+
strokeWidth: 1.2,
|
|
162
|
+
strokeDasharray: (isSkipped || isNextSkipped) ? '4 3' : undefined,
|
|
163
|
+
opacity: (isSkipped || isNextSkipped) ? 0.3 : 0.7,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return { nodes, edges };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Detail Content ───────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function WorkflowDetail({ wf }) {
|
|
176
|
+
return (
|
|
177
|
+
<div className="text-sm text-gray-200 space-y-4">
|
|
178
|
+
<p className="text-gray-300 text-xs leading-relaxed">{wf.description}</p>
|
|
179
|
+
|
|
180
|
+
<div className="flex gap-2">
|
|
181
|
+
<span className="px-2 py-0.5 bg-blue-900/50 border border-blue-700 rounded text-xs text-blue-300">
|
|
182
|
+
priority {wf.priority}
|
|
183
|
+
</span>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{/* Phases */}
|
|
187
|
+
<div>
|
|
188
|
+
<p className="text-gray-500 text-xs uppercase tracking-wide mb-1.5">Run Phases</p>
|
|
189
|
+
<div className="flex flex-wrap gap-1">
|
|
190
|
+
{(wf.phases.run || []).map((p) => (
|
|
191
|
+
<span key={p} className="px-2 py-0.5 bg-blue-900/50 border border-blue-700 rounded text-xs text-blue-300 font-mono">{p}</span>
|
|
192
|
+
))}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{wf.phases.skip?.length > 0 && (
|
|
197
|
+
<div>
|
|
198
|
+
<p className="text-gray-500 text-xs uppercase tracking-wide mb-1.5">Skip Phases</p>
|
|
199
|
+
<div className="flex flex-wrap gap-1">
|
|
200
|
+
{wf.phases.skip.map((p) => (
|
|
201
|
+
<span key={p} className="px-2 py-0.5 bg-gray-800 border border-dashed border-gray-600 rounded text-xs text-gray-500 font-mono line-through">{p}</span>
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Pause points */}
|
|
208
|
+
{wf.pausePoints?.length > 0 && (
|
|
209
|
+
<div>
|
|
210
|
+
<p className="text-gray-500 text-xs uppercase tracking-wide mb-1.5">Pause Points</p>
|
|
211
|
+
<div className="space-y-1.5">
|
|
212
|
+
{wf.pausePoints.map((pp, i) => (
|
|
213
|
+
<div key={i} className="px-2 py-1.5 bg-yellow-900/20 border border-yellow-700/40 rounded text-xs">
|
|
214
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
215
|
+
<span className="text-yellow-400 font-mono text-[10px]">{pp.phase}</span>
|
|
216
|
+
{pp.condition && (
|
|
217
|
+
<span className="text-gray-500 text-[10px]">if: {pp.condition}</span>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
<p className="text-yellow-200">{pp.question}</p>
|
|
221
|
+
</div>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{/* Keywords */}
|
|
228
|
+
{wf.keywords?.length > 0 && (
|
|
229
|
+
<div>
|
|
230
|
+
<p className="text-gray-500 text-xs uppercase tracking-wide mb-1.5">Keywords</p>
|
|
231
|
+
<div className="flex flex-wrap gap-1">
|
|
232
|
+
{wf.keywords.map((k) => (
|
|
233
|
+
<span key={k} className="px-1.5 py-0.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-400">{k}</span>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{/* Outputs */}
|
|
240
|
+
{wf.outputs?.required?.length > 0 && (
|
|
241
|
+
<div>
|
|
242
|
+
<p className="text-gray-500 text-xs uppercase tracking-wide mb-1.5">Required Outputs</p>
|
|
243
|
+
<div className="flex flex-wrap gap-1">
|
|
244
|
+
{wf.outputs.required.map((o) => (
|
|
245
|
+
<span key={o} className="px-2 py-0.5 bg-green-900/50 border border-green-700 rounded text-xs text-green-300 font-mono">{o}</span>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{wf.outputs?.optional?.length > 0 && (
|
|
252
|
+
<div>
|
|
253
|
+
<p className="text-gray-500 text-xs uppercase tracking-wide mb-1.5">Optional Outputs</p>
|
|
254
|
+
<div className="flex flex-wrap gap-1">
|
|
255
|
+
{wf.outputs.optional.map((o) => (
|
|
256
|
+
<span key={o} className="px-2 py-0.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-400 font-mono">{o}</span>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
{/* Criteria */}
|
|
263
|
+
{wf.criteria && Object.keys(wf.criteria).length > 0 && (
|
|
264
|
+
<div>
|
|
265
|
+
<p className="text-gray-500 text-xs uppercase tracking-wide mb-1.5">Criteria</p>
|
|
266
|
+
<pre className="p-2 bg-gray-950 border border-gray-800 rounded text-[9px] font-mono text-gray-400 overflow-x-auto">
|
|
267
|
+
{JSON.stringify(wf.criteria, null, 2)}
|
|
268
|
+
</pre>
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
export default function Workflows() {
|
|
278
|
+
const { workflows } = morphData;
|
|
279
|
+
const { panel, openPanel, closePanel } = useDetailPanel();
|
|
280
|
+
const [hoveredPhase, setHoveredPhase] = useState(null);
|
|
281
|
+
|
|
282
|
+
const { nodes, edges } = useMemo(() => buildGraph(workflows), [workflows]);
|
|
283
|
+
|
|
284
|
+
const handleNodeClick = useCallback(
|
|
285
|
+
(_event, node) => {
|
|
286
|
+
if (node.type === 'workflowLabelNode') {
|
|
287
|
+
const wf = node.data.wfRef;
|
|
288
|
+
if (wf) openPanel(wf.name, <WorkflowDetail wf={wf} />);
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
[openPanel]
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const hoverContextValue = useMemo(
|
|
295
|
+
() => ({ hoveredPhase, setHoveredPhase }),
|
|
296
|
+
[hoveredPhase]
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<div className="flex flex-col h-full">
|
|
301
|
+
{/* Header */}
|
|
302
|
+
<div className="px-6 py-4 border-b border-gray-800 flex-shrink-0">
|
|
303
|
+
<h1 className="text-white text-xl font-bold">Workflow Configs</h1>
|
|
304
|
+
<p className="text-gray-400 text-sm mt-0.5">
|
|
305
|
+
{workflows.length} workflow{workflows.length !== 1 ? 's' : ''} — each row shows 7 phases, grayed out phases are skipped. Click a workflow label to inspect.
|
|
306
|
+
</p>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div className="flex-1 relative">
|
|
310
|
+
<HoverContext.Provider value={hoverContextValue}>
|
|
311
|
+
<FlowGraph
|
|
312
|
+
nodes={nodes}
|
|
313
|
+
edges={edges}
|
|
314
|
+
nodeTypes={nodeTypes}
|
|
315
|
+
onNodeClick={handleNodeClick}
|
|
316
|
+
fitView
|
|
317
|
+
/>
|
|
318
|
+
</HoverContext.Provider>
|
|
319
|
+
|
|
320
|
+
<div className="absolute top-3 left-3 bg-gray-900/90 border border-gray-700 rounded-lg p-3 text-xs space-y-2 z-10">
|
|
321
|
+
<p className="text-gray-400 font-semibold uppercase tracking-wide text-[10px] mb-2">Legend</p>
|
|
322
|
+
<div className="flex items-center gap-2">
|
|
323
|
+
<span className="inline-block w-10 h-4 rounded-sm" style={{ background: '#1e3a5f', border: '1.5px solid #3b82f6' }} />
|
|
324
|
+
<span className="text-gray-300">Active phase</span>
|
|
325
|
+
</div>
|
|
326
|
+
<div className="flex items-center gap-2">
|
|
327
|
+
<span className="inline-block w-10 h-4 rounded-sm opacity-50" style={{ background: '#111827', border: '1.5px dashed #374151' }} />
|
|
328
|
+
<span className="text-gray-300">Skipped phase</span>
|
|
329
|
+
</div>
|
|
330
|
+
<div className="flex items-center gap-2">
|
|
331
|
+
<span className="inline-block w-10 h-4 rounded-sm" style={{ background: '#0f172a', border: '1.5px solid #334155' }} />
|
|
332
|
+
<span className="text-gray-300">Workflow label</span>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<div className="absolute top-3 right-3 bg-gray-900/90 border border-gray-700 rounded-lg px-3 py-1.5 text-xs text-gray-400 z-10">
|
|
337
|
+
{workflows.length} workflows · 7 phases
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<DetailPanel open={panel.open} title={panel.title} onClose={closePanel}>
|
|
342
|
+
{panel.content}
|
|
343
|
+
</DetailPanel>
|
|
344
|
+
</div>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "@xyflow/react/dist/style.css";
|
|
3
|
+
|
|
4
|
+
:root {
|
|
5
|
+
--tier-1: #ef4444;
|
|
6
|
+
--tier-2: #f97316;
|
|
7
|
+
--tier-3: #3b82f6;
|
|
8
|
+
--tier-4: #8b5cf6;
|
|
9
|
+
--gate-color: #fbbf24;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* React Flow dark theme overrides */
|
|
13
|
+
.react-flow {
|
|
14
|
+
background: #030712;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.react-flow__minimap {
|
|
18
|
+
background: #111827;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.react-flow__minimap-mask {
|
|
22
|
+
fill: #1f2937;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.react-flow__controls {
|
|
26
|
+
background: #111827;
|
|
27
|
+
border: 1px solid #374151;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.react-flow__controls-button {
|
|
31
|
+
background: #111827;
|
|
32
|
+
border-bottom: 1px solid #374151;
|
|
33
|
+
color: #9ca3af;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.react-flow__controls-button:hover {
|
|
37
|
+
background: #1f2937;
|
|
38
|
+
color: #f9fafb;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.react-flow__edge-path {
|
|
42
|
+
stroke: #4b5563;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.react-flow__node {
|
|
46
|
+
border-radius: 0.5rem;
|
|
47
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="dark bg-gray-950">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>morph-spec Dashboard</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body class="dark bg-gray-950 text-gray-100">
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/app/main.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "morph-spec-dashboard",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "^19.0.0",
|
|
13
|
+
"react-dom": "^19.0.0",
|
|
14
|
+
"react-router-dom": "^7.0.0",
|
|
15
|
+
"@xyflow/react": "^12.0.0",
|
|
16
|
+
"@dagrejs/dagre": "^1.1.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@vitejs/plugin-react": "^4.4.0",
|
|
20
|
+
"vite": "^6.0.0",
|
|
21
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
22
|
+
"tailwindcss": "^4.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join, basename } from 'path';
|
|
3
|
+
|
|
4
|
+
function findMdFiles(dir) {
|
|
5
|
+
const results = [];
|
|
6
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
7
|
+
const fullPath = join(dir, entry.name);
|
|
8
|
+
if (entry.isDirectory()) results.push(...findMdFiles(fullPath));
|
|
9
|
+
else if (entry.name.endsWith('.md') && entry.name !== 'README.md') results.push(fullPath);
|
|
10
|
+
}
|
|
11
|
+
return results;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function scanAgents(frameworkDir) {
|
|
15
|
+
const agentsJsonPath = join(frameworkDir, 'agents.json');
|
|
16
|
+
const agentsDir = join(frameworkDir, 'agents');
|
|
17
|
+
if (!existsSync(agentsJsonPath) || !existsSync(agentsDir)) {
|
|
18
|
+
return { version: 'unknown', total: 0, tiers: {}, list: [] };
|
|
19
|
+
}
|
|
20
|
+
const agentsJson = JSON.parse(readFileSync(agentsJsonPath, 'utf8'));
|
|
21
|
+
const mdFiles = findMdFiles(agentsDir);
|
|
22
|
+
const promptMap = {};
|
|
23
|
+
for (const filePath of mdFiles) {
|
|
24
|
+
promptMap[basename(filePath, '.md')] = readFileSync(filePath, 'utf8');
|
|
25
|
+
}
|
|
26
|
+
const list = Object.entries(agentsJson.agents).map(([id, agent]) => ({
|
|
27
|
+
id, color: agent.color, tier: agent.tier, title: agent.title, group: agent.group,
|
|
28
|
+
activePhases: agent.active_phases, reportsTo: agent.reports_to,
|
|
29
|
+
domains: agent.domains || [], keywords: agent.keywords || [],
|
|
30
|
+
validators: agent.validators || [], standards: agent.standards || [],
|
|
31
|
+
stacks: agent.stacks || [], prompt: promptMap[id] || '',
|
|
32
|
+
teammate: agent.teammate || null,
|
|
33
|
+
}));
|
|
34
|
+
return { version: agentsJson.version, total: agentsJson.total_agents, tiers: agentsJson.tiers, list };
|
|
35
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join, basename, dirname } from 'path';
|
|
3
|
+
|
|
4
|
+
function findJsFiles(dir) {
|
|
5
|
+
const results = [];
|
|
6
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
7
|
+
const fullPath = join(dir, entry.name);
|
|
8
|
+
if (entry.isDirectory()) results.push(...findJsFiles(fullPath));
|
|
9
|
+
else if (entry.name.endsWith('.js')) results.push(fullPath);
|
|
10
|
+
}
|
|
11
|
+
return results;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function extractJsDocDescription(source) {
|
|
15
|
+
const match = source.match(/\/\*\*\s*\n([\s\S]*?)\*\//);
|
|
16
|
+
if (!match) return '';
|
|
17
|
+
return match[1].split('\n').map(l => l.replace(/^\s*\*\s?/, '').trim()).filter(Boolean).join(' ');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractImports(source) {
|
|
21
|
+
const imports = [];
|
|
22
|
+
const regex = /import\s+.*?from\s+['"](.+?)['"]/g;
|
|
23
|
+
let m;
|
|
24
|
+
while ((m = regex.exec(source)) !== null) imports.push(m[1]);
|
|
25
|
+
return imports;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function scanHooks(frameworkDir) {
|
|
29
|
+
const hooksDir = join(frameworkDir, 'hooks', 'claude-code');
|
|
30
|
+
if (!existsSync(hooksDir)) return [];
|
|
31
|
+
return findJsFiles(hooksDir).map(filePath => {
|
|
32
|
+
const source = readFileSync(filePath, 'utf8');
|
|
33
|
+
return {
|
|
34
|
+
event: basename(dirname(filePath)),
|
|
35
|
+
name: basename(filePath, '.js'),
|
|
36
|
+
filename: basename(filePath),
|
|
37
|
+
source, imports: extractImports(source),
|
|
38
|
+
description: extractJsDocDescription(source),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { scanAgents } from './agents-scanner.js';
|
|
2
|
+
import { scanPhases } from './phases-scanner.js';
|
|
3
|
+
import { scanHooks } from './hooks-scanner.js';
|
|
4
|
+
import { scanSkills } from './skills-scanner.js';
|
|
5
|
+
import { scanStandards } from './standards-scanner.js';
|
|
6
|
+
import { scanWorkflows } from './workflows-scanner.js';
|
|
7
|
+
import { scanRules } from './rules-scanner.js';
|
|
8
|
+
import { scanSquads } from './squads-scanner.js';
|
|
9
|
+
import { scanRubrics } from './rubrics-scanner.js';
|
|
10
|
+
import { scanState } from './state-scanner.js';
|
|
11
|
+
|
|
12
|
+
export function scanAll(frameworkDir, projectDir) {
|
|
13
|
+
const agents = scanAgents(frameworkDir);
|
|
14
|
+
return {
|
|
15
|
+
agents, phases: scanPhases(frameworkDir), hooks: scanHooks(frameworkDir),
|
|
16
|
+
skills: scanSkills(frameworkDir), standards: scanStandards(frameworkDir, agents.list),
|
|
17
|
+
workflows: scanWorkflows(frameworkDir), rules: scanRules(frameworkDir),
|
|
18
|
+
squads: scanSquads(frameworkDir), rubrics: scanRubrics(frameworkDir),
|
|
19
|
+
liveState: scanState(projectDir || process.cwd()),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function scanPhases(frameworkDir) {
|
|
5
|
+
const phasesJson = JSON.parse(readFileSync(join(frameworkDir, 'phases.json'), 'utf8'));
|
|
6
|
+
const list = Object.values(phasesJson.phases).map((p) => ({
|
|
7
|
+
id: p.id, index: p.index, folder: p.folder || null, displayName: p.displayName,
|
|
8
|
+
optional: p.optional || false, goals: p.goals || [], requiredOutputs: p.requiredOutputs || [],
|
|
9
|
+
optionalOutputs: p.optionalOutputs || [], recommendedMCPs: p.recommendedMCPs || [],
|
|
10
|
+
pausePoints: p.pausePoints || [], agentTiers: p.agentTiers || [],
|
|
11
|
+
requiredSkills: p.requiredSkills || [],
|
|
12
|
+
}));
|
|
13
|
+
list.sort((a, b) => a.index - b.index);
|
|
14
|
+
return { version: phasesJson.version, list };
|
|
15
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function scanRubrics(frameworkDir) {
|
|
5
|
+
const dir = join(frameworkDir, 'rubrics');
|
|
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 { id: d.id, version: d.version || '1.0.0', description: d.description || '',
|
|
10
|
+
categories: d.categories || {}, thresholds: d.thresholds || { approve: 8.0, retry: 6.0 } };
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join, basename } from 'path';
|
|
3
|
+
|
|
4
|
+
function parseFrontmatter(content) {
|
|
5
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
6
|
+
if (!match) return { paths: [], stacks: [] };
|
|
7
|
+
const yaml = match[1];
|
|
8
|
+
const paths = []; const stacks = []; let currentList = null;
|
|
9
|
+
for (const line of yaml.split('\n')) {
|
|
10
|
+
if (line.startsWith('paths:')) { currentList = paths; continue; }
|
|
11
|
+
if (line.startsWith('stacks:')) { currentList = stacks; continue; }
|
|
12
|
+
if (line.match(/^\w/)) { currentList = null; continue; }
|
|
13
|
+
if (currentList !== null) {
|
|
14
|
+
const m = line.match(/^\s+-\s+"?(.+?)"?\s*$/);
|
|
15
|
+
if (m) currentList.push(m[1]);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { paths, stacks };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function extractStandardRefs(content) {
|
|
22
|
+
const refs = [];
|
|
23
|
+
const regex = /@\.morph\/framework\/standards\/(.+\.md)/g;
|
|
24
|
+
let m;
|
|
25
|
+
while ((m = regex.exec(content)) !== null) refs.push(m[1]);
|
|
26
|
+
return refs;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function scanRules(frameworkDir) {
|
|
30
|
+
const dir = join(frameworkDir, 'rules');
|
|
31
|
+
if (!existsSync(dir)) return [];
|
|
32
|
+
return readdirSync(dir).filter(f => f.endsWith('.md')).map(f => {
|
|
33
|
+
const content = readFileSync(join(dir, f), 'utf8');
|
|
34
|
+
const { paths, stacks } = parseFrontmatter(content);
|
|
35
|
+
return { name: basename(f, '.md'), filename: f, paths, stacks, content, standardRefs: extractStandardRefs(content) };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
function parseYamlFrontmatter(content) {
|
|
5
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
6
|
+
if (!match) return {};
|
|
7
|
+
const yaml = {};
|
|
8
|
+
for (const line of match[1].split('\n')) {
|
|
9
|
+
const colonIdx = line.indexOf(':');
|
|
10
|
+
if (colonIdx === -1) continue;
|
|
11
|
+
const key = line.slice(0, colonIdx).trim();
|
|
12
|
+
let value = line.slice(colonIdx + 1).trim();
|
|
13
|
+
if (value === 'true') value = true;
|
|
14
|
+
else if (value === 'false') value = false;
|
|
15
|
+
yaml[key] = value;
|
|
16
|
+
}
|
|
17
|
+
return yaml;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function scanSkills(frameworkDir) {
|
|
21
|
+
const skillsDir = join(frameworkDir, 'skills');
|
|
22
|
+
if (!existsSync(skillsDir)) return [];
|
|
23
|
+
const skills = [];
|
|
24
|
+
for (const levelDir of readdirSync(skillsDir, { withFileTypes: true })) {
|
|
25
|
+
if (!levelDir.isDirectory()) continue;
|
|
26
|
+
const levelPath = join(skillsDir, levelDir.name);
|
|
27
|
+
for (const skillDir of readdirSync(levelPath, { withFileTypes: true })) {
|
|
28
|
+
if (!skillDir.isDirectory()) continue;
|
|
29
|
+
const skillPath = join(levelPath, skillDir.name, 'SKILL.md');
|
|
30
|
+
if (!existsSync(skillPath)) continue;
|
|
31
|
+
const content = readFileSync(skillPath, 'utf8');
|
|
32
|
+
const fm = parseYamlFrontmatter(content);
|
|
33
|
+
skills.push({
|
|
34
|
+
id: skillDir.name, name: fm.name || skillDir.name,
|
|
35
|
+
description: fm.description || '', level: levelDir.name,
|
|
36
|
+
userInvocable: fm['user-invocable'] === true || fm['user-invocable'] === 'true',
|
|
37
|
+
allowedTools: typeof fm['allowed-tools'] === 'string' ? fm['allowed-tools'].split(',').map(t => t.trim()) : [],
|
|
38
|
+
content,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return skills;
|
|
43
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export function scanSquads(frameworkDir) {
|
|
5
|
+
const dir = join(frameworkDir, 'squad-templates');
|
|
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 { id: d.id, name: d.name, description: d.description || '',
|
|
10
|
+
parallel: d.parallel || false, coordination: d.coordination || null,
|
|
11
|
+
agents: d.agents || [], agentTeam: d.agent_team || null };
|
|
12
|
+
});
|
|
13
|
+
}
|